diff --git a/packages/@klickd/core/package.json b/packages/@klickd/core/package.json index 34a92ba..32f655b 100644 --- a/packages/@klickd/core/package.json +++ b/packages/@klickd/core/package.json @@ -33,7 +33,7 @@ "LICENSE" ], "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --external argon2 --external argon2-browser", + "build": "tsup src/index.ts --format esm,cjs --dts --external argon2 --external argon2-browser --external ajv --loader .json=copy", "test": "node --experimental-vm-modules node_modules/.bin/jest", "prepublishOnly": "npm run build" }, @@ -41,9 +41,18 @@ "canonicalize": "^2.0.0", "hash-wasm": "^4.11.0" }, + "peerDependencies": { + "ajv": "^8.12.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + }, "devDependencies": { "@types/jest": "^29.0.0", "@types/node": "^20.0.0", + "ajv": "^8.12.0", "argon2": "^0.44.0", "argon2-browser": "^1.18.0", "jest": "^29.0.0", diff --git a/packages/@klickd/core/src/__tests__/v4-ga-strict.test.ts b/packages/@klickd/core/src/__tests__/v4-ga-strict.test.ts new file mode 100644 index 0000000..47a09f3 --- /dev/null +++ b/packages/@klickd/core/src/__tests__/v4-ga-strict.test.ts @@ -0,0 +1,318 @@ +// @klickd/core — v4 GA strict schema + persona validation tests +// SPDX-License-Identifier: CC0-1.0 +// +// P0-4 (SDK TypeScript V4 GA alignment): mirrors the Python pytest matrix +// from packages/pypi/klickd/tests/test_v4_ga_strict.py against the +// TypeScript validate / validateIterErrors surface. +// +// Validation tests require the optional `ajv` peer dependency (installed +// as a devDependency for CI). Round-trip tests do NOT require ajv. + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + KlickdError, + saveKlickd, + loadKlickd, + validate, + validateIterErrors, + getBundledSchema, + listBundledSchemas, +} from '../index.js'; +import type { KlickdPayload } from '../index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..'); +const PERSONAS_DIR = join(REPO_ROOT, 'examples', 'v4', 'personas'); +const PASSPHRASE = 'correct-horse-battery-staple-v4'; + +function loadPersona(name: string): KlickdPayload { + return JSON.parse(readFileSync(join(PERSONAS_DIR, name), 'utf8')) as KlickdPayload; +} + +const PERSONA_FILES = existsSync(PERSONAS_DIR) + ? readdirSync(PERSONAS_DIR) + .filter((f) => f.endsWith('.klickd')) + .sort() + : []; + +// -- Sanity: personas dir and bundled schemas resolve ------------------------ + +describe('bundled v4 schemas', () => { + it('lists all four bundled schema keys', () => { + expect(listBundledSchemas().sort()).toEqual([ + 'payload-preview', + 'payload-strict', + 'unified-preview', + 'unified-strict', + ]); + }); + + it('payload-strict schema $id matches canonical', () => { + const s = getBundledSchema('payload-strict'); + expect(s.$id).toEqual('https://klickd.app/schemas/v4/klickd-payload.schema.json'); + }); + + it('unified-strict schema $id matches canonical', () => { + const s = getBundledSchema('unified-strict'); + expect(String(s.$id)).toMatch(/klickd\.schema\.json$/); + }); +}); + +describe('personas directory', () => { + it('contains 5 persona files', () => { + expect(PERSONA_FILES.length).toBe(5); + }); +}); + +// -- Strict validation: personas --------------------------------------------- + +describe.each(PERSONA_FILES)('persona %s', (persona) => { + it('validates against strict v4 payload schema', async () => { + await expect(validate(loadPersona(persona), { strict: true, target: 'payload' })).resolves.toBeUndefined(); + }); + + it('validates against strict v4 unified schema', async () => { + await expect(validate(loadPersona(persona), { strict: true, target: 'unified' })).resolves.toBeUndefined(); + }); + + it('validates against permissive v4 preview payload schema', async () => { + await expect(validate(loadPersona(persona), { strict: false, target: 'payload' })).resolves.toBeUndefined(); + }); +}); + +// -- Round-trip preservation (SPEC.md §33.7) --------------------------------- + +describe.each(PERSONA_FILES)('persona %s round-trip', (persona) => { + it('save → load preserves the persona verbatim', async () => { + const original = loadPersona(persona); + const envelope = await saveKlickd(original, { + passphrase: PASSPHRASE, + domain: (original.domain as string | undefined) ?? 'education', + }); + const recovered = await loadKlickd(envelope, { passphrase: PASSPHRASE }); + expect(recovered).toEqual(original); + }); + + it('double round-trip is stable', async () => { + const original = loadPersona(persona); + const once = await loadKlickd( + await saveKlickd(original, { passphrase: PASSPHRASE }), + { passphrase: PASSPHRASE }, + ); + const twice = await loadKlickd( + await saveKlickd(once, { passphrase: PASSPHRASE }), + { passphrase: PASSPHRASE }, + ); + expect(twice).toEqual(original); + }); +}); + +// -- Negative cases (must reject) -------------------------------------------- + +function minimalStrictPayload(): Record { + return { payload_schema_version: '4.0' }; +} + +describe('strict negative cases', () => { + it('rejects unknown gate level', async () => { + const bad = minimalStrictPayload(); + bad.verification_gates = { + version: 1, + gates: [{ action_class: 'x', level: 'loud' }], + }; + await expect(validate(bad, { strict: true })).rejects.toMatchObject({ code: 'KLICKD_E_SCHEMA' }); + }); + + it('rejects media entry missing hash', async () => { + const bad = minimalStrictPayload(); + bad.media_profile = { + version: 1, + entries: [{ id: 'x', modality: 'voice' }], + }; + await expect(validate(bad, { strict: true })).rejects.toMatchObject({ code: 'KLICKD_E_SCHEMA' }); + }); + + it('rejects unknown media modality', async () => { + const bad = minimalStrictPayload(); + bad.media_profile = { + version: 1, + entries: [ + { id: 'x', modality: 'video', hash: { algo: 'blake3', value: 'deadbeef' } }, + ], + }; + await expect(validate(bad, { strict: true })).rejects.toMatchObject({ code: 'KLICKD_E_SCHEMA' }); + }); + + it('rejects unsupported payload_schema_version', async () => { + await expect(validate({ payload_schema_version: '9.9' }, { strict: true })).rejects.toMatchObject({ + code: 'KLICKD_E_SCHEMA', + }); + }); + + it('rejects missing payload_schema_version', async () => { + await expect(validate({}, { strict: true })).rejects.toMatchObject({ code: 'KLICKD_E_SCHEMA' }); + }); + + it('rejects encrypted envelope missing kdf (unified target)', async () => { + const bad = { + klickd_version: '4.0', + created_at: '2026-05-24T00:00:00Z', + encrypted: true, + }; + await expect(validate(bad, { strict: true, target: 'unified' })).rejects.toMatchObject({ + code: 'KLICKD_E_SCHEMA', + }); + }); +}); + +// -- Both strict gate shapes accepted ---------------------------------------- + +describe('gate shapes', () => { + it('accepts the structured form', async () => { + const payload = minimalStrictPayload(); + payload.verification_gates = { + version: 1, + user_default: 'silent', + gates: [ + { id: 'g1', action_class: 'public_post', level: 'block' }, + { action_class: 'factual_claim_with_date', level: 'confirm' }, + ], + }; + await expect(validate(payload, { strict: true })).resolves.toBeUndefined(); + }); + + it('accepts the flat map form', async () => { + const payload = minimalStrictPayload(); + payload.verification_gates = { + public_post: 'block', + factual_claim_with_date: 'confirm', + }; + await expect(validate(payload, { strict: true })).resolves.toBeUndefined(); + }); + + it('rejects flat map with unknown level', async () => { + const payload = minimalStrictPayload(); + payload.verification_gates = { public_post: 'loud' }; + await expect(validate(payload, { strict: true })).rejects.toMatchObject({ code: 'KLICKD_E_SCHEMA' }); + }); +}); + +// -- Preview vs GA cross-acceptance ------------------------------------------ + +describe('preview/GA cross-acceptance', () => { + it("accepts payload_schema_version '4.0.0-preview.1' in strict schema", async () => { + await expect(validate({ payload_schema_version: '4.0.0-preview.1' }, { strict: true })).resolves.toBeUndefined(); + await expect(validate({ payload_schema_version: '4.0.0-preview.1' }, { strict: false })).resolves.toBeUndefined(); + }); + + it("accepts payload_schema_version '4.0' with preview marker in preview schema", async () => { + await expect( + validate({ payload_schema_version: '4.0', preview: 'v4.0.0-preview.1' }, { strict: false }), + ).resolves.toBeUndefined(); + }); +}); + +// -- v3.x backward compatibility --------------------------------------------- + +describe('v3.x non-regression', () => { + it('v3 payload still saves/loads (no v4 validation invoked)', async () => { + const v3: KlickdPayload = { + payload_schema_version: '3.0.0', + domain_schema_version: '1.0.0', + identity: { name: 'v3 user', language: 'fr' }, + agent_instructions: 'be concise', + }; + const envelope = await saveKlickd(v3, { passphrase: PASSPHRASE }); + const recovered = await loadKlickd(envelope, { passphrase: PASSPHRASE }); + expect(recovered).toEqual(v3); + }); + + it('v3 payload_schema_version fails v4 strict validation', async () => { + await expect(validate({ payload_schema_version: '3.0.0' }, { strict: true })).rejects.toMatchObject({ + code: 'KLICKD_E_SCHEMA', + }); + }); +}); + +// -- Optional registered profiles (media/project/gaming) --------------------- + +describe('registered profiles', () => { + it('RPG gaming persona validates strict and round-trips', async () => { + const persona = loadPersona('05-rpg-gamer-en.klickd'); + await expect(validate(persona, { strict: true, target: 'payload' })).resolves.toBeUndefined(); + const recovered = await loadKlickd( + await saveKlickd(persona, { passphrase: PASSPHRASE }), + { passphrase: PASSPHRASE }, + ); + expect(recovered).toEqual(persona); + }); + + it('créateur média persona validates strict and round-trips', async () => { + const persona = loadPersona('04-createur-media-fr.klickd'); + await expect(validate(persona, { strict: true, target: 'payload' })).resolves.toBeUndefined(); + const recovered = await loadKlickd( + await saveKlickd(persona, { passphrase: PASSPHRASE }), + { passphrase: PASSPHRASE }, + ); + expect(recovered).toEqual(persona); + }); + + it('chef de projet PME persona validates strict', async () => { + const persona = loadPersona('02-chef-projet-pme-fr.klickd'); + await expect(validate(persona, { strict: true, target: 'payload' })).resolves.toBeUndefined(); + }); +}); + +// -- iter_errors variant ----------------------------------------------------- + +describe('validateIterErrors', () => { + it('returns empty array on valid payload', async () => { + expect(await validateIterErrors(loadPersona(PERSONA_FILES[0]), { strict: true })).toEqual([]); + }); + + it('returns {path, message} entries on invalid payload', async () => { + const bad = minimalStrictPayload(); + bad.verification_gates = { public_post: 'loud' }; + const issues = await validateIterErrors(bad, { strict: true }); + expect(issues.length).toBeGreaterThan(0); + for (const i of issues) { + expect(typeof i.path).toBe('string'); + expect(typeof i.message).toBe('string'); + } + }); +}); + +// -- Unknown-field preservation under validation (§33.7) --------------------- + +describe('unknown-field preservation', () => { + it('unknown top-level field validates AND round-trips verbatim', async () => { + const payload: Record = minimalStrictPayload(); + payload.x_experimental_block = { future: true, values: [1, 2, 3] }; + await expect(validate(payload, { strict: true })).resolves.toBeUndefined(); + const recovered = await loadKlickd( + await saveKlickd(payload as KlickdPayload, { passphrase: PASSPHRASE }), + { passphrase: PASSPHRASE }, + ); + expect(recovered.x_experimental_block).toEqual(payload.x_experimental_block); + }); +}); + +// -- Optional-peer behaviour ------------------------------------------------- + +describe('KlickdError shape', () => { + it('validation failure carries code KLICKD_E_SCHEMA + 400 httpStatus', async () => { + try { + await validate({}, { strict: true }); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(KlickdError); + const err = e as KlickdError; + expect(err.code).toBe('KLICKD_E_SCHEMA'); + expect(err.httpStatus).toBe(400); + } + }); +}); diff --git a/packages/@klickd/core/src/index.ts b/packages/@klickd/core/src/index.ts index 340a4b7..6dd881e 100644 --- a/packages/@klickd/core/src/index.ts +++ b/packages/@klickd/core/src/index.ts @@ -5,3 +5,10 @@ export * from './types.js'; export * from './errors.js'; export { saveKlickd } from './encode.js'; export { loadKlickd } from './decode.js'; +export { + validate, + validateIterErrors, + getBundledSchema, + listBundledSchemas, +} from './validate.js'; +export type { ValidateOptions, ValidationIssue, ValidationTarget } from './validate.js'; diff --git a/packages/@klickd/core/src/schemas/klickd-payload-v4-preview.schema.json b/packages/@klickd/core/src/schemas/klickd-payload-v4-preview.schema.json new file mode 100644 index 0000000..e705b2f --- /dev/null +++ b/packages/@klickd/core/src/schemas/klickd-payload-v4-preview.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klickd.app/schemas/v4-preview/klickd-payload.schema.json", + "title": "klickd Payload v4 Preview (PERMISSIVE, NON-NORMATIVE)", + "description": "PREVIEW schema for the .klickd v4 inner payload, targeting v4.0.0-preview.1. This schema is INTENTIONALLY PERMISSIVE: it accepts and preserves draft v4 structures (media_profile, verification_gates, human_veto_policy, claim_sources, verification_artifacts, migration, context_cost, profile_kind) without performing strict validation. additionalProperties is true at every level so that unknown fields round-trip verbatim. This schema MUST NOT be used to reject a file that is otherwise a valid v3.5.1 file. The normative schemas remain klickd-envelope-v3.schema.json and klickd-payload-v3.schema.json. See SPEC.md §33 and docs/rfcs/ for the design source.", + "type": "object", + "additionalProperties": true, + "properties": { + "preview": { + "type": "string", + "description": "Marks a file as belonging to a specific preview iteration. Recommended value for files written against this schema: 'v4.0.0-preview.1'. Absent in v3.x files." + }, + "payload_schema_version": { + "type": "string", + "description": "Payload schema version. PERMISSIVE in this preview: any string is accepted. A future strict v4 schema MAY pin this to a concrete value." + }, + "domain_schema_version": { + "type": "string", + "description": "Domain-specific schema version (e.g. 'education-1.2'). PERMISSIVE in this preview." + }, + "profile_kind": { + "type": "string", + "description": "Top-level discriminator for the profile shape. Common values: 'learner', 'agent', 'team', 'robot'. v3.x is implicitly 'learner'; the preview makes this explicit and extensible. Custom strings are permitted." + }, + "media_profile": { + "description": "RFC-001: portable, hash-referenced media context. Bytes live outside the .klickd file by default; the payload carries metadata, hashes, and (optionally) inline base64 below a documented threshold. PERMISSIVE: structure is not enforced here.", + "type": ["object", "array", "null"], + "additionalProperties": true + }, + "verification_gates": { + "description": "RFC-002 v1: user's preferred friction profile, mapping action class to gate level (silent / warn / confirm / block / require-owner). PERMISSIVE: not enforced here.", + "type": ["object", "array", "null"], + "additionalProperties": true + }, + "human_veto_policy": { + "description": "RFC-002 v1: standing rules about when a human MUST be in the loop, regardless of agent confidence. Overrides any lower gate. PERMISSIVE: not enforced here.", + "type": ["object", "null"], + "additionalProperties": true + }, + "claim_sources": { + "description": "RFC-002 v1 + v2-additive: declarative preferences for where to ground factual claims, and records of what was actually used. PERMISSIVE: not enforced here.", + "type": ["object", "null"], + "additionalProperties": true + }, + "verification_artifacts": { + "description": "RFC-002 §8b.8: pointer ledger of outputs already produced by expensive verification commands (test suites, builds, web fetches, DOI resolutions). MUST be a pointer ledger, not a payload sink. PERMISSIVE: structure is not enforced here.", + "type": ["array", "null"] + }, + "error_journal": { + "description": "RFC-002 v1: append-only lessons learned that should influence future gate evaluation. PERMISSIVE: not enforced here.", + "type": ["array", "null"] + }, + "risk_thresholds": { + "description": "RFC-002 v1: numeric / categorical knobs (e.g. public_reach, financial_amount_eur_max_silent). PERMISSIVE.", + "type": ["object", "null"], + "additionalProperties": true + }, + "preflight_checks": { + "description": "RFC-002 v1: small, named checks an agent SHOULD run before acting on certain classes. PERMISSIVE.", + "type": ["array", "null"] + }, + "contract_tests": { + "description": "RFC-002 v2-additive: machine-checkable contract tests bound to action classes. PERMISSIVE.", + "type": ["array", "null"] + }, + "success_criteria": { + "description": "RFC-002 v2-additive: declarative success criteria per action class. PERMISSIVE.", + "type": ["object", "array", "null"], + "additionalProperties": true + }, + "reversibility": { + "description": "RFC-002 v2-additive: declared reversibility for action classes. PERMISSIVE.", + "type": ["object", "null"], + "additionalProperties": true + }, + "blast_radius": { + "description": "RFC-002 v2-additive: declared blast radius for action classes. PERMISSIVE.", + "type": ["object", "null"], + "additionalProperties": true + }, + "migration": { + "description": "RFC-004: optional migration metadata block. Audit-only in this preview — no migration tooling ships with v4.0.0-preview.1. PERMISSIVE.", + "type": ["object", "null"], + "additionalProperties": true, + "properties": { + "source_version": {"type": "string"}, + "migrated_at": {"type": "string"}, + "migration_report_ref": {"type": "string"}, + "backup_ref": {"type": "string"} + } + }, + "context_cost": { + "description": "Research/benchmark track (benchmarks/context_cost/RFC.md): optional fields recording measured 'repeated context waste' for this profile. No normative semantics depend on this field. PERMISSIVE.", + "type": ["object", "null"], + "additionalProperties": true + } + } +} diff --git a/packages/@klickd/core/src/schemas/klickd-payload-v4.schema.json b/packages/@klickd/core/src/schemas/klickd-payload-v4.schema.json new file mode 100644 index 0000000..89b2f42 --- /dev/null +++ b/packages/@klickd/core/src/schemas/klickd-payload-v4.schema.json @@ -0,0 +1,354 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klickd.app/schemas/v4/klickd-payload.schema.json", + "title": "klickd Payload v4 (Strict GA candidate, normative)", + "description": "Strict JSON Schema for the .klickd v4 inner payload. This schema is the GA strict equivalent of the permissive klickd-payload-v4-preview.schema.json. Strict blocks correspond to RFC-001 (media_profile v1, Accepted) and RFC-002 v1 core (verification_gates, human_veto_policy, claim_sources v1, Accepted). Sections that remain Draft (RFC-002 v2-additive: contract_tests, success_criteria, reversibility, blast_radius, verification_artifacts, error_journal.rule_created; RFC-004 migration runtime) are accepted but left permissive so they can be tightened by a future strict schema bump without breaking GA writers. Top-level additionalProperties is TRUE to preserve unknown fields verbatim (SPEC.md §33.7 forward-compatibility invariant). Strict shape on frozen sub-objects uses additionalProperties: false locally. Does NOT supersede the permissive preview schema (klickd-payload-v4-preview.schema.json) — both coexist until the preview sunset announced separately.", + "type": "object", + "required": [ + "payload_schema_version" + ], + "additionalProperties": true, + "properties": { + "payload_schema_version": { + "type": "string", + "description": "Payload schema version. For GA strict files, the canonical value is '4.0'. The preview value '4.0.0-preview.1' is also accepted so a preview file already in the wild round-trips against the strict schema without forcing a rewrite. A future strict bump (4.1, etc.) MAY tighten this enum.", + "enum": ["4.0", "4.0.0-preview.1"] + }, + "domain_schema_version": { + "type": "string", + "description": "Domain-specific schema version. Two accepted forms: (a) v3-style '{domain}-{major}.{minor}' (e.g. 'education-1.2'); (b) bare semver-ish '{major}.{minor}(.{patch})?' for domain-agnostic profiles (e.g. '1.0.0').", + "pattern": "^([a-z][a-z0-9_-]*-\\d+\\.\\d+|\\d+\\.\\d+(\\.\\d+)?)$" + }, + "preview": { + "type": "string", + "description": "OPTIONAL preview marker. Present only on files written against a preview iteration (e.g. 'v4.0.0-preview.1'). Absent on GA strict files." + }, + "profile_kind": { + "type": "string", + "description": "Top-level discriminator for the profile shape. Reserved canonical values: 'learner', 'agent', 'team', 'robot', 'creator'. Custom strings are permitted (extension space). v3.x is implicitly 'learner'." + }, + "injection_target": { + "type": "string", + "enum": ["system_prompt", "user_message", "both"], + "description": "Where to inject the .klickd context (carried over from v3.x §20.2)." + }, + "identity": { + "type": "object", + "description": "Persistent user identity attributes. Carried over from v3 SPEC §8; same strictness as v3 payload schema. All sub-fields are OPTIONAL.", + "additionalProperties": true, + "properties": { + "name": {"type": "string", "maxLength": 256}, + "display_name": {"type": "string", "maxLength": 256, "description": "v4 GA preferred user-facing alias. Either 'name' or 'display_name' may be used; producers SHOULD pick one."}, + "language": {"type": "string", "description": "BCP 47 language tag."}, + "timezone": {"type": "string", "description": "IANA time zone identifier."}, + "communication_style": {"type": "string"} + } + }, + "companion_identity": { + "type": "object", + "description": "Optional companion / co-agent persona. Same surface as v3.4 §27. Permissive.", + "additionalProperties": true + }, + "context": { + "type": "object", + "description": "Operational session state. Carried over from v3 SPEC §9. Permissive (unevaluatedProperties policy of v3 retained).", + "additionalProperties": true + }, + "knowledge": { + "type": "object", + "description": "Structured knowledge state. Carried over from v3 SPEC §10. Permissive.", + "additionalProperties": true + }, + "memory": { + "type": "array", + "maxItems": 1000, + "description": "Structured conversation/event log. Same v3 §11 constraints retained.", + "items": {"type": "object", "additionalProperties": true} + }, + "learning_goal": { + "type": "object", + "description": "User's stated learning / delivery goal. Same surface as v3 §27. Permissive.", + "additionalProperties": true, + "properties": { + "type": {"type": "string"}, + "deadline": {"type": "string"}, + "stakes": {"type": "string", "enum": ["low", "medium", "high"]} + } + }, + "user_preferences": { + "oneOf": [ + {"type": "string", "maxLength": 32768}, + {"type": "object", "additionalProperties": true} + ], + "description": "Canonical type is string (v3.5 §22.6). Object form retained for backward compatibility." + }, + "agent_instructions": { + "type": "string", + "maxLength": 32768, + "description": "System-prompt injection string. Same v3 §7.2 contract." + }, + "onboarding_trigger": { + "type": "string", + "description": "Optional onboarding trigger (v3.4 §29b). Common values: 'on_new_agent', 'manual'." + }, + "ethics": { + "type": "object", + "description": "Ethical guard surface (v3.x). Permissive in this schema; safety / locked fields are governed by docs/spec/DEPRECATION_POLICY_V4.md §9.", + "additionalProperties": true + }, + "media_profile": { + "description": "RFC-001 v1 (Accepted): portable, hash-referenced media context. Strict on the v1 frozen surface (versioned + entries[]); a permissive 'summary' form (preview-era) is also accepted for round-trip compatibility. New modalities / fields land via additive RFC bumps.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["version", "entries"], + "properties": { + "version": {"type": "integer", "const": 1, "description": "media_profile schema version. v1 per RFC-001."}, + "entries": { + "type": "array", + "maxItems": 256, + "items": {"$ref": "#/$defs/mediaProfileEntry"} + } + } + }, + { + "type": "object", + "description": "Permissive summary form (preview-era). Strict producers SHOULD migrate to the versioned + entries[] form. Kept for round-trip parity with vectors_v40_preview.json.", + "additionalProperties": true, + "not": {"required": ["version", "entries"]} + }, + { + "type": "null" + } + ] + }, + "verification_gates": { + "description": "RFC-002 v1 core (Accepted): user's preferred friction profile per action class. Two accepted shapes: (a) structured form (recommended) with version + gates[]; (b) flat map {action_class: level} for compactness (used by §33.5 minimal example and preview readers).", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["version", "gates"], + "properties": { + "version": {"type": "integer", "const": 1}, + "user_default": {"$ref": "#/$defs/gateLevel"}, + "gates": { + "type": "array", + "maxItems": 256, + "items": {"$ref": "#/$defs/gateEntry"} + } + } + }, + { + "type": "object", + "description": "Flat map form: {action_class: gate_level}. Each value MUST be one of the five canonical levels.", + "additionalProperties": {"$ref": "#/$defs/gateLevel"} + } + ] + }, + "human_veto_policy": { + "description": "RFC-002 v1 (Accepted): standing rules about when a human MUST be in the loop. Overrides any lower gate.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "applies_to": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 64, + "description": "List of action classes for which a human veto is required." + }, + "second_party": { + "type": ["string", "null"], + "description": "Optional named second party who may unlock. null means owner-only." + }, + "min_level": { + "$ref": "#/$defs/gateLevel", + "description": "Floor gate level. The policy MUST NOT resolve below this level for any action class in 'applies_to'. RFC-002 v1 §4 #6 — human veto is sacred." + }, + "rationale": {"type": "string", "maxLength": 1024} + } + }, + "claim_sources": { + "description": "RFC-002 v1 (Accepted) + v2-additive (Draft). Strict on v1 fields ('prefer', 'require_citation_for'); 'records' and extended fields remain permissive (v2 still Draft).", + "type": ["object", "null"], + "additionalProperties": true, + "properties": { + "prefer": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 64, + "description": "Ordered preference list. Each entry is a free-form source identifier; common values: 'user_supplied', 'tool:web_search', 'tool:doi_resolver'." + }, + "require_citation_for": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 64 + }, + "records": { + "type": "array", + "description": "v2-additive (Draft). Permissive until promoted.", + "items": {"type": "object", "additionalProperties": true} + } + } + }, + "risk_thresholds": { + "description": "RFC-002 v1 (Accepted): numeric / categorical knobs. Permissive on inner shape — extension space.", + "type": ["object", "null"], + "additionalProperties": true + }, + "preflight_checks": { + "description": "RFC-002 v1 (Accepted): small named checks to run before acting. Each entry is an object with at minimum 'name'.", + "type": ["array", "null"], + "items": {"type": ["object", "string"], "additionalProperties": true} + }, + "error_journal": { + "description": "RFC-002 v1 (Accepted): append-only lessons learned. Permissive on entry shape ('rule_created' remains Draft in v2-additive).", + "type": ["array", "null"], + "items": {"type": "object", "additionalProperties": true} + }, + "verification_artifacts": { + "description": "RFC-002 §8b.8 (Draft, v2-additive). Permissive: pointer ledger, not a payload sink. Accepted as null or array.", + "type": ["array", "null"], + "items": {"type": "object", "additionalProperties": true} + }, + "contract_tests": { + "description": "RFC-002 v2-additive (Draft). Permissive.", + "type": ["array", "null"], + "items": {"type": "object", "additionalProperties": true} + }, + "success_criteria": { + "description": "RFC-002 v2-additive (Draft). Permissive.", + "type": ["object", "array", "null"], + "additionalProperties": true + }, + "reversibility": { + "description": "RFC-002 v2-additive (Draft). Permissive — typical shape is {action_class: enum} but enums are not yet frozen.", + "type": ["object", "null"], + "additionalProperties": true + }, + "blast_radius": { + "description": "RFC-002 v2-additive (Draft). Permissive.", + "type": ["object", "null"], + "additionalProperties": true + }, + "migration": { + "description": "RFC-004 (Draft): optional migration metadata block. Audit-only — no migration tooling ships with the strict schema. Strict on v1 frozen fields; extension fields permitted.", + "type": ["object", "null"], + "additionalProperties": true, + "properties": { + "source_version": {"type": "string"}, + "migrated_at": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$" + }, + "migration_report_ref": {"type": "string"}, + "backup_ref": {"type": "string"} + } + }, + "context_cost": { + "description": "Research/benchmark track (benchmarks/context_cost/RFC.md). No spec semantics depend on this field. Permissive.", + "type": ["object", "null"], + "additionalProperties": true + }, + "gaming_profile": { + "description": "R4-P1-4 gaming.klickd baseline (preview, registry-based). NOT a v4 MUST. Accepted as a registry-defined optional profile; permissive structure.", + "type": ["object", "null"], + "additionalProperties": true + }, + "deprecated_fields": { + "description": "OPTIONAL informational block defined by docs/spec/DEPRECATION_POLICY_V4.md §6. Lists fields the producer has emitted as 'deprecated' status. Readers that don't understand it MUST silently ignore (§33.7).", + "type": ["array", "null"], + "items": { + "type": "object", + "additionalProperties": true, + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "since": {"type": "string"}, + "replacement": {"type": ["string", "null"]}, + "removal_target": {"type": ["string", "null"]} + } + } + }, + "_example_metadata": { + "description": "Optional non-normative block used by repository examples (see examples/v4/personas/README.md). Carries 'persona', 'non_normative', 'contains_real_pii', 'contains_secrets', etc. A reader MUST NOT use this block as a trust signal.", + "type": "object", + "additionalProperties": true + } + }, + "$defs": { + "gateLevel": { + "type": "string", + "enum": ["silent", "warn", "confirm", "block", "require-owner"], + "description": "RFC-002 v1 §6 — fixed enum of five gate levels. No new levels are added in GA." + }, + "gateEntry": { + "type": "object", + "additionalProperties": false, + "required": ["action_class", "level"], + "properties": { + "id": {"type": "string", "maxLength": 128}, + "action_class": {"type": "string", "maxLength": 128}, + "level": {"$ref": "#/$defs/gateLevel"}, + "reason": {"type": "string", "maxLength": 1024} + } + }, + "mediaProfileEntry": { + "type": "object", + "additionalProperties": true, + "required": ["id", "modality", "hash"], + "description": "RFC-001 v1 entry. Strict on v1 frozen fields (id, modality, hash). Additional metadata (consent, producer, etc.) is permitted as extension surface.", + "properties": { + "id": {"type": "string", "maxLength": 128}, + "modality": { + "type": "string", + "enum": ["voice", "image", "document", "embedding"], + "description": "RFC-001 v1 §4 #3 — closed enum at v1. Custom modalities live under 'x_*' namespaces (outside this entry)." + }, + "label": {"type": "string", "maxLength": 256}, + "language": {"type": "string"}, + "uri": {"type": "string", "description": "May be file://, https://, ipfs://, cas://, or a relative path. Reader chooses how to resolve."}, + "media_type": {"type": "string"}, + "byte_size": {"type": "integer", "minimum": 0}, + "duration_ms": {"type": "integer", "minimum": 0}, + "bytes_b64": { + "type": "string", + "contentEncoding": "base64", + "description": "Inline base64 PERMITTED ONLY for entries ≤ 16 KiB total (RFC-001 v1 §4 #1). Strict enforcement of the 16 KiB cap is not expressible in JSON Schema and is enforced by reader-side logic." + }, + "hash": { + "type": "object", + "additionalProperties": false, + "required": ["algo", "value"], + "properties": { + "algo": {"type": "string", "const": "blake3", "description": "RFC-001 v1 §4 #2 — one hash, one algorithm: BLAKE3."}, + "value": {"type": "string", "description": "Hash value, encoding per RFC-001 (base64 or hex-prefixed). Strict format enforcement is reader-side."} + } + }, + "producer": { + "type": "object", + "additionalProperties": true, + "properties": { + "kind": {"type": "string"}, + "device": {"type": "string"} + } + }, + "consent": { + "type": "object", + "additionalProperties": true, + "description": "RFC-001 v1 §4 #5 — consent is per-entry, not per-file.", + "properties": { + "purposes": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 64 + }, + "expires_at": {"type": "string"}, + "revocable": {"type": "boolean"} + } + } + } + } + } +} diff --git a/packages/@klickd/core/src/schemas/klickd-v4-preview.schema.json b/packages/@klickd/core/src/schemas/klickd-v4-preview.schema.json new file mode 100644 index 0000000..26d47fc --- /dev/null +++ b/packages/@klickd/core/src/schemas/klickd-v4-preview.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klickd.app/schema/v4-preview/klickd.schema.json", + "title": "klickd v4 Preview — Unified (PERMISSIVE, NON-NORMATIVE)", + "description": "PREVIEW unified schema for .klickd v4, targeting v4.0.0-preview.1. This schema is INTENTIONALLY PERMISSIVE: it validates the top-level shape of a v4-preview file (envelope + optional preview hooks) without enforcing strict v4 semantics. additionalProperties is true so unknown fields round-trip verbatim. Use this for single-pass acceptance of draft v4 documents. For pre-/post-decrypt validation, see schemas/klickd-payload-v4-preview.schema.json. The normative production schemas remain schema/klickd-v3.4.schema.json (unified) and schemas/klickd-envelope-v3.schema.json + schemas/klickd-payload-v3.schema.json (split). See SPEC.md §33.", + "type": "object", + "additionalProperties": true, + "required": ["klickd_version", "created_at", "encrypted"], + "properties": { + "klickd_version": { + "type": "string", + "description": "Wire / envelope version. PERMISSIVE in this preview — any MAJOR.MINOR string is accepted. Preview producers SHOULD emit '4.0' and pair it with a 'preview' field. v3.x producers MUST continue to emit '3.x' and MUST NOT emit a 'preview' field.", + "pattern": "^\\d+\\.\\d+(\\.[0-9A-Za-z-.]+)?$" + }, + "preview": { + "type": "string", + "description": "Marks a file as belonging to a specific preview iteration. Recommended value for files written against this schema: 'v4.0.0-preview.1'. Absent in v3.x and in a future GA v4.0." + }, + "created_at": { + "type": "string", + "description": "RFC 3339 UTC timestamp of file creation. Same contract as v3.x.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "format": "date-time" + }, + "encrypted": { + "type": "boolean", + "description": "Whether the payload is AES-256-GCM encrypted. Envelope cryptography is unchanged from v3.x in this preview." + }, + "domain": { + "type": "string", + "description": "Semantic category of the context. Same surface as v3.x; custom strings remain permitted." + }, + "profile_kind": { + "type": "string", + "description": "Top-level discriminator for the profile shape. Common values: 'learner', 'agent', 'team', 'robot'. Custom strings are permitted." + }, + "ciphertext": { + "type": "string", + "description": "AES-256-GCM ciphertext when encrypted is true. Same contract as v3.x.", + "contentEncoding": "base64" + }, + "iv": { + "type": "string", + "contentEncoding": "base64" + }, + "kdf_salt": { + "type": "string", + "contentEncoding": "base64" + }, + "kdf": { + "type": "object", + "additionalProperties": true, + "description": "Structured KDF block as in envelope-v3. Unchanged in this preview." + }, + "cipher": { + "type": "object", + "additionalProperties": true, + "description": "Structured cipher block as in envelope-v3. Unchanged in this preview." + }, + "media_profile": { + "description": "RFC-001 (preview hook). PERMISSIVE — see schemas/klickd-payload-v4-preview.schema.json.", + "type": ["object", "array", "null"], + "additionalProperties": true + }, + "verification_gates": { + "description": "RFC-002 (preview hook). PERMISSIVE.", + "type": ["object", "array", "null"], + "additionalProperties": true + }, + "human_veto_policy": { + "description": "RFC-002 (preview hook). PERMISSIVE.", + "type": ["object", "null"], + "additionalProperties": true + }, + "claim_sources": { + "description": "RFC-002 (preview hook). PERMISSIVE.", + "type": ["object", "null"], + "additionalProperties": true + }, + "verification_artifacts": { + "description": "RFC-002 §8b.8 (preview hook). PERMISSIVE — pointer ledger, not a payload sink.", + "type": ["array", "null"] + }, + "migration": { + "description": "RFC-004 (preview hook). Audit-only in v4.0.0-preview.1.", + "type": ["object", "null"], + "additionalProperties": true + }, + "context_cost": { + "description": "Research/benchmark track (preview hook). No normative semantics.", + "type": ["object", "null"], + "additionalProperties": true + } + } +} diff --git a/packages/@klickd/core/src/schemas/klickd-v4.schema.json b/packages/@klickd/core/src/schemas/klickd-v4.schema.json new file mode 100644 index 0000000..824557c --- /dev/null +++ b/packages/@klickd/core/src/schemas/klickd-v4.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klickd.app/schema/v4/klickd.schema.json", + "title": "klickd v4 — Unified (Strict GA candidate, normative)", + "description": "Strict unified JSON Schema for a .klickd v4 document (envelope + optional inline payload). This schema is the GA strict equivalent of the permissive klickd-v4-preview.schema.json. It validates the envelope shape strictly (mirroring the v3 envelope contract — Argon2id + AES-256-GCM is unchanged in v4 per SPEC.md §33.10 #2) and, for unencrypted files, validates the inline payload against the strict v4 payload schema (klickd-payload-v4.schema.json). Top-level additionalProperties is TRUE to preserve unknown fields verbatim (SPEC.md §33.7 forward-compatibility invariant). Does NOT supersede the permissive preview schema (klickd-v4-preview.schema.json) — both coexist.", + "type": "object", + "additionalProperties": true, + "required": ["klickd_version", "created_at", "encrypted"], + "properties": { + "klickd_version": { + "type": "string", + "description": "Wire / envelope version. Strict v4 GA producers SHOULD emit '4.0'. v3.x values are also accepted because the v3 envelope contract is unchanged (§33.10 #2) and a v3.x file remains readable by a v4 reader.", + "pattern": "^(3|4)\\.\\d+(\\.[0-9A-Za-z-.]+)?$" + }, + "preview": { + "type": "string", + "description": "OPTIONAL preview marker. Absent on GA strict files. If present, this file was written against a preview iteration (e.g. 'v4.0.0-preview.1')." + }, + "created_at": { + "type": "string", + "description": "RFC 3339 UTC timestamp of file creation. Z-suffix only, no fractional seconds (matches v3 envelope strict contract).", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "encrypted": { + "type": "boolean" + }, + "domain": { + "type": "string", + "minLength": 1, + "description": "Semantic category. Registered values follow v3 (education, work, finance, legal, creative, health, research, robotics, gaming, creator). Custom non-empty strings are permitted." + }, + "profile_kind": { + "type": "string", + "description": "Top-level discriminator for the profile shape. Reserved canonical values: 'learner', 'agent', 'team', 'robot', 'creator'." + }, + "domain_schema_version": { + "type": "string", + "pattern": "^([a-z][a-z0-9_-]*-\\d+\\.\\d+|\\d+\\.\\d+(\\.\\d+)?)$" + }, + "kdf": { + "type": "object", + "description": "Structured KDF block — same contract as envelope-v3 §15.", + "required": ["name", "params", "salt"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "enum": ["argon2id", "pbkdf2-sha256"]}, + "params": { + "oneOf": [ + { + "type": "object", + "required": ["m", "t", "p"], + "additionalProperties": false, + "properties": { + "m": {"type": "integer", "minimum": 65536}, + "t": {"type": "integer", "minimum": 1}, + "p": {"type": "integer", "minimum": 1} + } + }, + { + "type": "object", + "required": ["iterations"], + "additionalProperties": false, + "properties": { + "iterations": {"type": "integer", "minimum": 600000} + } + } + ] + }, + "salt": {"type": "string"} + } + }, + "cipher": { + "type": "object", + "description": "Structured cipher block — same contract as envelope-v3 §16. v4 does not introduce a new cipher.", + "required": ["name", "iv"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "const": "AES-256-GCM"}, + "iv": {"type": "string"} + } + }, + "ciphertext": { + "type": "string", + "description": "AES-256-GCM ciphertext (base64 padded). Required when encrypted=true." + }, + "iv": { + "type": "string", + "description": "Legacy top-level IV mirror, retained for v3.0 round-trip parity. New producers SHOULD place IV inside the structured cipher block." + }, + "kdf_salt": { + "type": "string", + "description": "Legacy top-level salt mirror, retained for v3.0 round-trip parity." + }, + "payload_schema_version": { + "type": "string", + "description": "Payload schema version when the payload is inline (encrypted=false). Strict v4 GA value: '4.0'. Preview '4.0.0-preview.1' accepted for round-trip.", + "enum": ["4.0", "4.0.0-preview.1"] + }, + "media_profile": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/media_profile"}, + "verification_gates": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/verification_gates"}, + "human_veto_policy": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/human_veto_policy"}, + "claim_sources": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/claim_sources"}, + "verification_artifacts": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/verification_artifacts"}, + "contract_tests": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/contract_tests"}, + "success_criteria": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/success_criteria"}, + "reversibility": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/reversibility"}, + "blast_radius": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/blast_radius"}, + "risk_thresholds": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/risk_thresholds"}, + "preflight_checks": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/preflight_checks"}, + "error_journal": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/error_journal"}, + "migration": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/migration"}, + "context_cost": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/context_cost"}, + "gaming_profile": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/gaming_profile"}, + "deprecated_fields": {"$ref": "https://klickd.app/schemas/v4/klickd-payload.schema.json#/properties/deprecated_fields"} + }, + "allOf": [ + { + "if": { + "type": "object", + "properties": {"encrypted": {"const": true}}, + "required": ["encrypted"] + }, + "then": { + "required": ["kdf", "cipher", "ciphertext"], + "description": "Encrypted v4 file MUST carry kdf + cipher + ciphertext (envelope-v3 contract retained)." + } + } + ] +} diff --git a/packages/@klickd/core/src/types.ts b/packages/@klickd/core/src/types.ts index 067feaf..12f9456 100644 --- a/packages/@klickd/core/src/types.ts +++ b/packages/@klickd/core/src/types.ts @@ -100,10 +100,94 @@ export interface KlickdPayload { context?: KlickdContext; knowledge?: KlickdKnowledge; memory?: KlickdMemoryEntry[]; + // v4 additive surface (preview + GA). Strict shape on v1-frozen fields. + profile_kind?: string; + preview?: string; + onboarding_trigger?: string; + media_profile?: KlickdMediaProfileV1 | Record | null; + verification_gates?: KlickdVerificationGatesV1 | Record | null; + human_veto_policy?: KlickdHumanVetoPolicy | null; + claim_sources?: KlickdClaimSources | null; + migration?: KlickdMigrationV1 | null; + risk_thresholds?: Record | null; + preflight_checks?: unknown[] | null; + error_journal?: unknown[] | null; + verification_artifacts?: unknown[] | null; + contract_tests?: unknown[] | null; + success_criteria?: unknown; + reversibility?: Record | null; + blast_radius?: Record | null; + context_cost?: Record | null; + gaming_profile?: Record | null; + deprecated_fields?: Array> | null; /** Domain extension fields */ [key: string]: unknown; } +// v4 additive structured shapes — parity with packages/pypi/klickd/src/klickd/_types.py. + +export type KlickdMediaModality = 'voice' | 'image' | 'document' | 'embedding'; + +export interface KlickdMediaProfileEntryHash { + algo: 'blake3'; + value: string; +} + +export interface KlickdMediaProfileEntry { + id: string; + modality: KlickdMediaModality; + hash: KlickdMediaProfileEntryHash; + label?: string; + language?: string; + uri?: string; + media_type?: string; + byte_size?: number; + duration_ms?: number; + bytes_b64?: string; + producer?: Record; + consent?: Record; +} + +export interface KlickdMediaProfileV1 { + version: 1; + entries: KlickdMediaProfileEntry[]; +} + +export type KlickdGateLevel = 'silent' | 'warn' | 'confirm' | 'block' | 'require-owner'; + +export interface KlickdGateEntry { + action_class: string; + level: KlickdGateLevel; + id?: string; + reason?: string; +} + +export interface KlickdVerificationGatesV1 { + version: 1; + gates: KlickdGateEntry[]; + user_default?: KlickdGateLevel; +} + +export interface KlickdHumanVetoPolicy { + applies_to?: string[]; + second_party?: string | null; + min_level?: KlickdGateLevel; + rationale?: string; +} + +export interface KlickdClaimSources { + prefer?: string[]; + require_citation_for?: string[]; + records?: Array>; +} + +export interface KlickdMigrationV1 { + source_version?: string; + migrated_at?: string; + migration_report_ref?: string; + backup_ref?: string; +} + export interface LoadKlickdOptions { passphrase?: string; /** Enable legacy v2.x PBKDF2-SHA256/600k reading. Default: false */ diff --git a/packages/@klickd/core/src/validate.ts b/packages/@klickd/core/src/validate.ts new file mode 100644 index 0000000..061a206 --- /dev/null +++ b/packages/@klickd/core/src/validate.ts @@ -0,0 +1,198 @@ +// .klickd v4 — strict / preview JSON-Schema validation (TypeScript) +// SPDX-License-Identifier: CC0-1.0 +// +// P0-4 (SDK TypeScript v4 GA alignment): mirrors the Python SDK's +// `validate(payload, strict=..., target=...)` and `validate_iter_errors` +// surface against the bundled v4 schemas: +// +// - klickd-payload-v4.schema.json (strict GA candidate, P0-2) +// - klickd-payload-v4-preview.schema.json (permissive v4 preview) +// - klickd-v4.schema.json (unified strict GA) +// - klickd-v4-preview.schema.json (unified preview) +// +// Validation is OPTIONAL: `ajv` is an optional peer dependency. Callers +// who do not invoke `validate()` continue to work with only the v3 +// envelope crypto deps. Round-trip preservation (SPEC.md §33.7) is the +// canonical forward-compat contract — validation does not modify the +// payload. + +import payloadStrict from './schemas/klickd-payload-v4.schema.json' with { type: 'json' }; +import payloadPreview from './schemas/klickd-payload-v4-preview.schema.json' with { type: 'json' }; +import unifiedStrict from './schemas/klickd-v4.schema.json' with { type: 'json' }; +import unifiedPreview from './schemas/klickd-v4-preview.schema.json' with { type: 'json' }; + +import { KlickdError, HTTP_STATUS } from './errors.js'; + +export type ValidationTarget = 'payload' | 'unified'; + +export interface ValidateOptions { + /** True (default) = v4 GA strict schema; false = permissive preview. */ + strict?: boolean; + /** + * "payload" (default) validates the inner payload schema. + * "unified" validates against the unified envelope+payload schema. + */ + target?: ValidationTarget; +} + +export interface ValidationIssue { + /** JSON-pointer-like path ("" / "" when at root). */ + path: string; + /** Validator-produced human-readable message. */ + message: string; +} + +const SCHEMAS = { + 'payload-strict': payloadStrict as Record, + 'payload-preview': payloadPreview as Record, + 'unified-strict': unifiedStrict as Record, + 'unified-preview': unifiedPreview as Record, +} as const; + +type SchemaKey = keyof typeof SCHEMAS; + +/** + * Return a parsed copy of one of the four bundled v4 schemas. Provides the + * same affordance as Python `klickd.validate._load_schema` — useful for + * tooling that wants to introspect the bundled JSON without reaching back + * into the repo. + */ +export function getBundledSchema(key: SchemaKey): Record { + const s = SCHEMAS[key]; + if (!s) { + throw new Error(`Unknown schema key: ${String(key)}`); + } + // Deep clone so callers can mutate freely. + return JSON.parse(JSON.stringify(s)) as Record; +} + +/** List of bundled schema keys (parity with Python `_SCHEMA_FILES`). */ +export function listBundledSchemas(): SchemaKey[] { + return Object.keys(SCHEMAS) as SchemaKey[]; +} + +// Lazy Ajv import + per-key validator cache. Ajv 2020 is the entry point +// for Draft 2020-12, which is what these schemas declare. +type ValidatorFn = ((data: unknown) => boolean) & { + errors?: Array<{ instancePath?: string; schemaPath?: string; message?: string }> | null; +}; + +interface AjvBundle { + validators: Map; +} + +let ajvBundle: AjvBundle | null = null; + +async function getAjv(): Promise { + if (ajvBundle) return ajvBundle; + + let AjvCtor: new (opts?: Record) => { + addSchema: (s: unknown, key?: string) => unknown; + compile: (s: unknown) => ValidatorFn; + getSchema: (id: string) => ValidatorFn | undefined; + }; + try { + const mod = await import('ajv/dist/2020.js'); + AjvCtor = (mod as { default?: typeof AjvCtor }).default ?? (mod as unknown as typeof AjvCtor); + } catch (e) { + throw new KlickdError( + 'KLICKD_E_SCHEMA', + "validate() requires the optional 'ajv' dependency (>=8.12, Draft 2020-12). " + + "Install it with: npm install ajv", + HTTP_STATUS['KLICKD_E_SCHEMA'], + ); + } + + // strict:false → tolerate unknown formats (e.g. "date-time") and unknown + // keywords. Our schemas use a small custom vocabulary that Ajv's strict + // mode would otherwise warn about; the runtime check itself is unaffected. + const ajv = new AjvCtor({ allErrors: true, strict: false, validateFormats: false }); + + // Register all four schemas first so cross-schema $ref resolves locally + // (parity with the Python `referencing.Registry` setup). + for (const key of listBundledSchemas()) { + ajv.addSchema(SCHEMAS[key], `klickd:${key}`); + } + + const validators = new Map(); + for (const key of listBundledSchemas()) { + validators.set(key, ajv.compile(SCHEMAS[key])); + } + + ajvBundle = { validators }; + return ajvBundle; +} + +function keyFor(target: ValidationTarget, strict: boolean): SchemaKey { + return `${target}-${strict ? 'strict' : 'preview'}` as SchemaKey; +} + +function formatPath(instancePath: string | undefined): string { + if (!instancePath || instancePath === '') return ''; + // Ajv yields RFC 6901 JSON pointer ("/foo/0/bar"). Strip leading "/". + return instancePath.replace(/^\//, ''); +} + +/** + * Validate a .klickd payload (or unified envelope+payload) against the v4 + * JSON schema bundled with this package. Throws KlickdError(KLICKD_E_SCHEMA) + * on validation failure or when the optional `ajv` peer is missing. + * + * Mirrors Python `klickd.validate`. See packages/pypi/klickd/src/klickd/validate.py. + */ +export async function validate( + payload: unknown, + options: ValidateOptions = {}, +): Promise { + const strict = options.strict ?? true; + const target = options.target ?? 'payload'; + if (target !== 'payload' && target !== 'unified') { + throw new Error(`target must be 'payload' or 'unified', got ${String(target)}`); + } + + const { validators } = await getAjv(); + const validator = validators.get(keyFor(target, strict)); + if (!validator) { + throw new Error(`No bundled validator for ${target}-${strict ? 'strict' : 'preview'}`); + } + + if (validator(payload)) return; + + const errors = validator.errors ?? []; + const summary = errors + .slice(0, 8) + .map((e) => `${formatPath(e.instancePath)}: ${(e.message ?? '').slice(0, 200)}`); + const extra = errors.length > 8 ? ` (+${errors.length - 8} more)` : ''; + throw new KlickdError( + 'KLICKD_E_SCHEMA', + `v4 ${strict ? 'strict' : 'preview'} ${target} validation failed${extra}: ${summary.join(' | ')}`, + HTTP_STATUS['KLICKD_E_SCHEMA'], + ); +} + +/** + * Non-throwing variant. Returns an array of {path, message} issues — empty + * when the payload is valid. Mirrors Python `validate_iter_errors`. + */ +export async function validateIterErrors( + payload: unknown, + options: ValidateOptions = {}, +): Promise { + const strict = options.strict ?? true; + const target = options.target ?? 'payload'; + if (target !== 'payload' && target !== 'unified') { + throw new Error(`target must be 'payload' or 'unified', got ${String(target)}`); + } + + const { validators } = await getAjv(); + const validator = validators.get(keyFor(target, strict)); + if (!validator) { + throw new Error(`No bundled validator for ${target}-${strict ? 'strict' : 'preview'}`); + } + + if (validator(payload)) return []; + return (validator.errors ?? []).map((e) => ({ + path: formatPath(e.instancePath), + message: e.message ?? '', + })); +}