diff --git a/.github/workflows/test-vectors.yml b/.github/workflows/test-vectors.yml index 098ae47..008435f 100644 --- a/.github/workflows/test-vectors.yml +++ b/.github/workflows/test-vectors.yml @@ -19,10 +19,10 @@ jobs: run: pip install cryptography argon2-cffi - name: Run all suites (v2.5 + v3.0 + adversarial + v4.0-preview) run: python3 verify_vectors.py - - name: Run klickd Python package tests (incl. v4 preview round-trip) + - name: Run klickd Python package tests (incl. v4 preview round-trip + v4 GA strict validation) working-directory: packages/pypi/klickd run: | - pip install -e . + pip install -e ".[validate]" pip install pytest python -m pytest tests/ -q diff --git a/packages/pypi/klickd/README.md b/packages/pypi/klickd/README.md index d674c60..176bda6 100644 --- a/packages/pypi/klickd/README.md +++ b/packages/pypi/klickd/README.md @@ -62,17 +62,53 @@ payload = load_klickd(file_bytes, passphrase="my-passphrase", legacy=True) --- -## `.klickd` v4 preview fields (additive, non-GA) +## `.klickd` v4 — GA strict + preview fields -This library currently targets the **v3** envelope and is **stable at v3.5.1**. -The v4 preview track (`v4.0.0-preview.1`, NOT GA) introduces additive payload -fields such as `profile_kind`, `media_profile`, `verification_gates`, -`claim_sources`, `verification_artifacts`, `migration`, and `context_cost`. +This library targets the **v3** envelope on the wire (envelope crypto contract +is frozen at v3 per SPEC.md §33.10 #2) and ships the **v4 GA strict candidate +payload schema** for validation. The v4 preview track (`v4.0.0-preview.1`) +remains accepted in parallel. -These fields are **preserved verbatim** on round-trip — `load_klickd` returns -the raw decrypted JSON object and `save_klickd` re-encrypts it without -filtering unknown keys. Strict v4 validation, migrations, and business-logic -helpers are intentionally **not** implemented yet. +The v4 additive payload fields — `profile_kind`, `media_profile`, +`verification_gates`, `human_veto_policy`, `claim_sources`, +`verification_artifacts`, `migration`, `context_cost`, `deprecated_fields` — +are **preserved verbatim** on round-trip (SPEC.md §33.7). `load_klickd` +returns the raw decrypted JSON object; `save_klickd` re-encrypts it without +filtering unknown keys. + +### Optional v4 schema validation + +Install the optional `validate` extra to enable strict / preview schema +validation against the bundled v4 JSON schemas (RFC-001 v1, RFC-002 v1 +core, RFC-004 v1): + +```bash +pip install klickd[validate] +``` + +```python +from klickd import validate, validate_iter_errors + +payload = { + "payload_schema_version": "4.0", + "verification_gates": { + "version": 1, + "gates": [ + {"action_class": "public_post", "level": "block"}, + ], + }, +} + +validate(payload, strict=True) # raises KlickdError(KLICKD_E_SCHEMA) on fail + +# Non-raising variant that returns every (path, message): +errors = validate_iter_errors(payload, strict=True) +``` + +`validate(..., strict=False)` selects the permissive v4 preview schema. +`validate(..., target="unified")` selects the unified envelope+payload +schema. The strict schema accepts both `"4.0"` and `"4.0.0-preview.1"` +in `payload_schema_version` so preview files round-trip without rewriting. ```python v4_preview_payload = { diff --git a/packages/pypi/klickd/pyproject.toml b/packages/pypi/klickd/pyproject.toml index 1977897..4204523 100644 --- a/packages/pypi/klickd/pyproject.toml +++ b/packages/pypi/klickd/pyproject.toml @@ -30,6 +30,11 @@ dependencies = [ "typing-extensions>=4.8", ] +[project.optional-dependencies] +# Optional extra for v4 JSON-Schema validation. The base SDK can load/save +# .klickd files without jsonschema; only klickd.validate.validate() needs it. +validate = ["jsonschema>=4.18"] + [project.urls] Homepage = "https://klickd.app" Repository = "https://github.com/Davincc77/klickdskill" @@ -37,3 +42,6 @@ Documentation = "https://github.com/Davincc77/klickdskill/blob/main/SPEC.md" [tool.hatch.build.targets.wheel] packages = ["src/klickd"] +# Schema files under src/klickd/schemas/ are part of the package tree and +# auto-included by hatchling when packages = ["src/klickd"] selects the +# parent directory. No explicit include rule is required. diff --git a/packages/pypi/klickd/src/klickd/__init__.py b/packages/pypi/klickd/src/klickd/__init__.py index 88bee48..e4f952a 100644 --- a/packages/pypi/klickd/src/klickd/__init__.py +++ b/packages/pypi/klickd/src/klickd/__init__.py @@ -9,6 +9,7 @@ from .decode import load_klickd from .encode import save_klickd from .errors import KlickdError, KlickdErrorCode, HTTP_STATUS +from .validate import validate, validate_iter_errors from ._types import ( KlickdPayload, KlickdEnvelope, @@ -16,12 +17,21 @@ KlickdIdentity, KlickdContext, KlickdKnowledge, + KlickdMediaProfileEntry, + KlickdMediaProfileV1, + KlickdGateEntry, + KlickdVerificationGatesV1, + KlickdHumanVetoPolicy, + KlickdClaimSources, + KlickdMigrationV1, ) __version__ = "4.0.0a1" __all__ = [ "load_klickd", "save_klickd", + "validate", + "validate_iter_errors", "KlickdError", "KlickdErrorCode", "HTTP_STATUS", @@ -31,5 +41,12 @@ "KlickdIdentity", "KlickdContext", "KlickdKnowledge", + "KlickdMediaProfileEntry", + "KlickdMediaProfileV1", + "KlickdGateEntry", + "KlickdVerificationGatesV1", + "KlickdHumanVetoPolicy", + "KlickdClaimSources", + "KlickdMigrationV1", "__version__", ] diff --git a/packages/pypi/klickd/src/klickd/_types.py b/packages/pypi/klickd/src/klickd/_types.py index e9e44c2..ae77bed 100644 --- a/packages/pypi/klickd/src/klickd/_types.py +++ b/packages/pypi/klickd/src/klickd/_types.py @@ -73,6 +73,67 @@ class KlickdKnowledge(TypedDict, total=False): next_steps: List[str] +class KlickdMediaProfileEntryHash(TypedDict): + algo: Literal["blake3"] + value: str + + +class KlickdMediaProfileEntry(TypedDict, total=False): + id: Required[str] + modality: Required[Literal["voice", "image", "document", "embedding"]] + hash: Required[KlickdMediaProfileEntryHash] + label: str + language: str + uri: str + media_type: str + byte_size: int + duration_ms: int + bytes_b64: str + producer: dict + consent: dict + + +class KlickdMediaProfileV1(TypedDict, total=False): + version: Required[Literal[1]] + entries: Required[List[KlickdMediaProfileEntry]] + + +KlickdGateLevel = Literal["silent", "warn", "confirm", "block", "require-owner"] + + +class KlickdGateEntry(TypedDict, total=False): + action_class: Required[str] + level: Required[KlickdGateLevel] + id: str + reason: str + + +class KlickdVerificationGatesV1(TypedDict, total=False): + version: Required[Literal[1]] + gates: Required[List[KlickdGateEntry]] + user_default: KlickdGateLevel + + +class KlickdHumanVetoPolicy(TypedDict, total=False): + applies_to: List[str] + second_party: Optional[str] + min_level: KlickdGateLevel + rationale: str + + +class KlickdClaimSources(TypedDict, total=False): + prefer: List[str] + require_citation_for: List[str] + records: List[Any] + + +class KlickdMigrationV1(TypedDict, total=False): + source_version: str + migrated_at: str + migration_report_ref: str + backup_ref: str + + class KlickdPayload(TypedDict, total=False): payload_schema_version: Required[str] domain_schema_version: Required[str] @@ -84,3 +145,23 @@ class KlickdPayload(TypedDict, total=False): context: KlickdContext knowledge: KlickdKnowledge memory: List[KlickdMemoryEntry] + # v4 additive surface (preview + GA). Strict shape on v1-frozen fields. + profile_kind: str + preview: str + onboarding_trigger: str + media_profile: Union[KlickdMediaProfileV1, dict] + verification_gates: Union[KlickdVerificationGatesV1, dict] + human_veto_policy: Optional[KlickdHumanVetoPolicy] + claim_sources: Optional[KlickdClaimSources] + migration: Optional[KlickdMigrationV1] + risk_thresholds: Optional[dict] + preflight_checks: Optional[List[Any]] + error_journal: Optional[List[Any]] + verification_artifacts: Optional[List[Any]] + contract_tests: Optional[List[Any]] + success_criteria: Optional[Any] + reversibility: Optional[dict] + blast_radius: Optional[dict] + context_cost: Optional[dict] + gaming_profile: Optional[dict] + deprecated_fields: Optional[List[Any]] diff --git a/packages/pypi/klickd/src/klickd/schemas/klickd-payload-v4-preview.schema.json b/packages/pypi/klickd/src/klickd/schemas/klickd-payload-v4-preview.schema.json new file mode 100644 index 0000000..e705b2f --- /dev/null +++ b/packages/pypi/klickd/src/klickd/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/pypi/klickd/src/klickd/schemas/klickd-payload-v4.schema.json b/packages/pypi/klickd/src/klickd/schemas/klickd-payload-v4.schema.json new file mode 100644 index 0000000..89b2f42 --- /dev/null +++ b/packages/pypi/klickd/src/klickd/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/pypi/klickd/src/klickd/schemas/klickd-v4-preview.schema.json b/packages/pypi/klickd/src/klickd/schemas/klickd-v4-preview.schema.json new file mode 100644 index 0000000..26d47fc --- /dev/null +++ b/packages/pypi/klickd/src/klickd/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/pypi/klickd/src/klickd/schemas/klickd-v4.schema.json b/packages/pypi/klickd/src/klickd/schemas/klickd-v4.schema.json new file mode 100644 index 0000000..824557c --- /dev/null +++ b/packages/pypi/klickd/src/klickd/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/pypi/klickd/src/klickd/validate.py b/packages/pypi/klickd/src/klickd/validate.py new file mode 100644 index 0000000..bb45642 --- /dev/null +++ b/packages/pypi/klickd/src/klickd/validate.py @@ -0,0 +1,159 @@ +# .klickd v4 — strict / preview JSON-Schema validation +# SPDX-License-Identifier: CC0-1.0 +# +# P0-3 (SDK Python V4 GA alignment): exposes `validate(payload, strict=...)` +# 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: `jsonschema` is not a hard 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. + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Tuple + +from .errors import KlickdError, KlickdErrorCode + +# Map a schema target onto its bundled file. The "unified" targets validate +# the full envelope shape (klickd_version, encrypted, kdf, cipher, ...). +# The "payload" targets validate the decrypted inner payload only. +_SCHEMA_FILES: Dict[str, str] = { + "payload-strict": "klickd-payload-v4.schema.json", + "payload-preview": "klickd-payload-v4-preview.schema.json", + "unified-strict": "klickd-v4.schema.json", + "unified-preview": "klickd-v4-preview.schema.json", +} + +_SCHEMA_DIR = Path(__file__).resolve().parent / "schemas" + + +def _load_schema(name: str) -> dict: + fname = _SCHEMA_FILES.get(name) + if not fname: + raise ValueError(f"Unknown schema target: {name!r}") + path = _SCHEMA_DIR / fname + return json.loads(path.read_text(encoding="utf-8")) + + +def _require_jsonschema(): + try: + import jsonschema # noqa: F401 + from jsonschema import Draft202012Validator + except ImportError as exc: + raise KlickdError( + KlickdErrorCode.FORMAT, + "validate() requires the optional 'jsonschema' dependency. " + "Install it with: pip install jsonschema", + ) from exc + return Draft202012Validator + + +def _build_validator(name: str): + """Build a Draft 2020-12 validator with the companion schema registered + so cross-schema $refs (unified ↔ payload) resolve locally and never + trigger a network fetch.""" + Draft202012Validator = _require_jsonschema() + schema = _load_schema(name) + + # Build a registry that includes the companion schema of the requested + # target. We bundle four schemas; for any unified-* target the payload-* + # schema MAY be $ref'd, and vice versa. + try: + from referencing import Registry, Resource + from referencing.jsonschema import DRAFT202012 + except ImportError: + # Older jsonschema/referencing: fall back to no registry. The + # bundled strict schemas embed their refs by $id and modern + # jsonschema resolves them via the deprecated RefResolver path + # (we accept the deprecation warning). + return Draft202012Validator(schema) + + resources = [] + for key in _SCHEMA_FILES: + s = _load_schema(key) + if "$id" in s: + resources.append((s["$id"], Resource(contents=s, specification=DRAFT202012))) + + registry = Registry().with_resources(resources) + return Draft202012Validator(schema, registry=registry) + + +def validate( + payload: Dict[str, Any], + strict: bool = True, + target: str = "payload", +) -> None: + """ + Validate a .klickd payload (or unified envelope+payload) against the v4 + JSON schema bundled with this SDK. + + Args: + payload: The decrypted payload dict (or unified envelope dict if + ``target="unified"``). + strict: ``True`` (default) uses the v4 GA strict schema candidate + (RFC-001 v1, RFC-002 v1 core, RFC-004 v1 frozen surface). + ``False`` uses the permissive v4 preview schema. + target: ``"payload"`` (default) validates the inner payload schema. + ``"unified"`` validates against the unified envelope+payload + schema (use this when the dict carries envelope fields like + ``klickd_version`` / ``encrypted``). + + Raises: + KlickdError: ``KLICKD_E_SCHEMA`` if validation fails or if + ``jsonschema`` is not installed. The exception's + ``args[0]`` includes the list of validation errors, + truncated to the first 8 messages. + """ + if target not in ("payload", "unified"): + raise ValueError(f"target must be 'payload' or 'unified', got {target!r}") + + key = f"{target}-{'strict' if strict else 'preview'}" + validator = _build_validator(key) + errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.path)) + + if not errors: + return + + summary: List[str] = [] + for err in errors[:8]: + path = "/".join(str(p) for p in err.absolute_path) or "" + summary.append(f"{path}: {err.message[:200]}") + extra = "" if len(errors) <= 8 else f" (+{len(errors) - 8} more)" + raise KlickdError( + KlickdErrorCode.SCHEMA, + f"v4 {'strict' if strict else 'preview'} {target} validation failed{extra}: " + + " | ".join(summary), + ) + + +def validate_iter_errors( + payload: Dict[str, Any], + strict: bool = True, + target: str = "payload", +) -> List[Tuple[str, str]]: + """ + Non-raising variant of :func:`validate`. Returns a list of + ``(path, message)`` tuples — empty when the payload is valid. + + Useful when a writer wants to surface all validation issues at once + (e.g. the R4-P0-1 wizard's reload-verification step) instead of + failing fast on the first one. + """ + if target not in ("payload", "unified"): + raise ValueError(f"target must be 'payload' or 'unified', got {target!r}") + + key = f"{target}-{'strict' if strict else 'preview'}" + validator = _build_validator(key) + out: List[Tuple[str, str]] = [] + for err in sorted(validator.iter_errors(payload), key=lambda e: list(e.path)): + path = "/".join(str(p) for p in err.absolute_path) or "" + out.append((path, err.message)) + return out diff --git a/packages/pypi/klickd/tests/test_v4_ga_strict.py b/packages/pypi/klickd/tests/test_v4_ga_strict.py new file mode 100644 index 0000000..385a47d --- /dev/null +++ b/packages/pypi/klickd/tests/test_v4_ga_strict.py @@ -0,0 +1,314 @@ +# klickd — v4 GA strict schema + persona round-trip tests +# SPDX-License-Identifier: CC0-1.0 +# +# P0-3 (SDK Python V4 GA alignment): validates the SDK's strict / preview +# validation surface against the five R4-P0-3 persona examples and against +# negative cases. Round-trip behaviour (SPEC.md §33.7 unknown-field +# preservation) is verified for every persona. +# +# These tests intentionally do NOT cover the wizard-only docs-only error +# codes from R4-P0-2 (KLICKD_E_PASS_MISMATCH, KLICKD_E_SAVE_LOCAL, +# KLICKD_E_LEGACY_VERSION, KLICKD_E_CORRUPT, KLICKD_E_POLICY_LOCKED, +# KLICKD_E_UNSAFE_QR) — the R4-P0-2 spec explicitly defers SDK alignment +# of those codes to a later track. + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from klickd import ( + KlickdError, + KlickdErrorCode, + load_klickd, + save_klickd, + validate, + validate_iter_errors, +) + +# Validation tests require the optional `jsonschema` dependency. +# Install with `pip install klickd[validate]` (or `pip install jsonschema`). +# When absent, every validation test in this file is skipped — load/save +# tests in test_roundtrip.py and test_v4_preview_roundtrip.py still run. +jsonschema = pytest.importorskip("jsonschema") + +REPO_ROOT = Path(__file__).resolve().parents[4] +PERSONAS_DIR = REPO_ROOT / "examples" / "v4" / "personas" +PASSPHRASE = "correct-horse-battery-staple-v4" + + +def _load_persona(name: str) -> dict: + return json.loads((PERSONAS_DIR / name).read_text(encoding="utf-8")) + + +PERSONA_FILES = sorted( + p.name for p in PERSONAS_DIR.glob("*.klickd") +) if PERSONAS_DIR.is_dir() else [] + + +# -- Sanity: personas directory and bundled schemas resolve ------------------ + + +def test_personas_dir_exists(): + assert PERSONAS_DIR.is_dir(), f"missing personas dir: {PERSONAS_DIR}" + assert PERSONA_FILES, "no persona examples found" + assert len(PERSONA_FILES) == 5, f"expected 5 personas, got {len(PERSONA_FILES)}" + + +def test_bundled_schemas_resolve(): + from klickd import validate as _v # re-export available + assert callable(_v) + # The validate module bundles four schema files; load one to prove it. + from klickd.validate import _load_schema # type: ignore[attr-defined] + payload_schema = _load_schema("payload-strict") + assert payload_schema["$id"].endswith("klickd-payload.schema.json") + unified_schema = _load_schema("unified-strict") + assert unified_schema["$id"].endswith("klickd.schema.json") + + +# -- Strict validation: personas --------------------------------------------- + + +@pytest.mark.parametrize("persona", PERSONA_FILES) +def test_persona_passes_strict_payload(persona): + """Each R4-P0-3 persona MUST validate against the strict v4 payload schema.""" + data = _load_persona(persona) + # Personas carry envelope fields too; validate the payload schema first + # (which is permissive on unknown top-level envelope fields). + validate(data, strict=True, target="payload") + + +@pytest.mark.parametrize("persona", PERSONA_FILES) +def test_persona_passes_strict_unified(persona): + """Each persona MUST also validate against the unified strict schema.""" + data = _load_persona(persona) + validate(data, strict=True, target="unified") + + +@pytest.mark.parametrize("persona", PERSONA_FILES) +def test_persona_passes_preview_payload(persona): + """Each persona MUST also validate against the permissive preview schema.""" + data = _load_persona(persona) + validate(data, strict=False, target="payload") + + +# -- Round-trip preservation: §33.7 ------------------------------------------ + + +@pytest.mark.parametrize("persona", PERSONA_FILES) +def test_persona_roundtrips_unknown_fields(persona): + """save_klickd → load_klickd MUST preserve every persona field verbatim.""" + original = _load_persona(persona) + envelope = save_klickd(original, PASSPHRASE, domain=original.get("domain", "education")) + recovered = load_klickd(envelope, passphrase=PASSPHRASE) + assert recovered == original, f"persona {persona} mutated on round-trip" + + +@pytest.mark.parametrize("persona", PERSONA_FILES) +def test_persona_double_roundtrip_stable(persona): + """Two consecutive round-trips MUST be stable (idempotent).""" + original = _load_persona(persona) + once = load_klickd(save_klickd(original, PASSPHRASE), passphrase=PASSPHRASE) + twice = load_klickd(save_klickd(once, PASSPHRASE), passphrase=PASSPHRASE) + assert twice == original + + +# -- Strict validation: negative cases (must reject) ------------------------- + + +def _minimal_strict_payload() -> dict: + return {"payload_schema_version": "4.0"} + + +def test_unknown_gate_level_rejected(): + bad = _minimal_strict_payload() + bad["verification_gates"] = { + "version": 1, + "gates": [{"action_class": "x", "level": "loud"}], + } + with pytest.raises(KlickdError) as exc: + validate(bad, strict=True) + assert exc.value.code == KlickdErrorCode.SCHEMA + + +def test_media_entry_missing_hash_rejected(): + bad = _minimal_strict_payload() + bad["media_profile"] = { + "version": 1, + "entries": [{"id": "x", "modality": "voice"}], + } + with pytest.raises(KlickdError) as exc: + validate(bad, strict=True) + assert exc.value.code == KlickdErrorCode.SCHEMA + + +def test_unknown_media_modality_rejected(): + bad = _minimal_strict_payload() + bad["media_profile"] = { + "version": 1, + "entries": [ + { + "id": "x", + "modality": "video", # not in v1 enum + "hash": {"algo": "blake3", "value": "deadbeef"}, + } + ], + } + with pytest.raises(KlickdError) as exc: + validate(bad, strict=True) + assert exc.value.code == KlickdErrorCode.SCHEMA + + +def test_unsupported_payload_schema_version_rejected(): + with pytest.raises(KlickdError) as exc: + validate({"payload_schema_version": "9.9"}, strict=True) + assert exc.value.code == KlickdErrorCode.SCHEMA + + +def test_missing_payload_schema_version_rejected(): + with pytest.raises(KlickdError) as exc: + validate({}, strict=True) + assert exc.value.code == KlickdErrorCode.SCHEMA + + +def test_encrypted_envelope_missing_kdf_rejected_unified(): + bad = { + "klickd_version": "4.0", + "created_at": "2026-05-24T00:00:00Z", + "encrypted": True, + } + with pytest.raises(KlickdError) as exc: + validate(bad, strict=True, target="unified") + assert exc.value.code == KlickdErrorCode.SCHEMA + + +# -- Both strict gate shapes accepted ---------------------------------------- + + +def test_structured_gates_form_accepted(): + payload = _minimal_strict_payload() + 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"}, + ], + } + validate(payload, strict=True) + + +def test_flat_gates_form_accepted(): + payload = _minimal_strict_payload() + payload["verification_gates"] = { + "public_post": "block", + "factual_claim_with_date": "confirm", + } + validate(payload, strict=True) + + +def test_flat_gates_unknown_level_rejected(): + payload = _minimal_strict_payload() + payload["verification_gates"] = {"public_post": "loud"} + with pytest.raises(KlickdError) as exc: + validate(payload, strict=True) + assert exc.value.code == KlickdErrorCode.SCHEMA + + +# -- Preview-style files validate against both schemas ----------------------- + + +def test_preview_value_accepted_in_strict_schema(): + """payload_schema_version '4.0.0-preview.1' MUST be accepted by strict (P0-2 design).""" + payload = {"payload_schema_version": "4.0.0-preview.1"} + validate(payload, strict=True) + validate(payload, strict=False) + + +def test_ga_value_accepted_in_preview_schema(): + payload = {"payload_schema_version": "4.0", "preview": "v4.0.0-preview.1"} + validate(payload, strict=False) + + +# -- v3.x backward compatibility (no regression) ----------------------------- + + +def test_v3_payload_unaffected_by_v4_validator(): + """A v3.0 payload MUST still load/save without invoking v4 validation.""" + v3_payload = { + "payload_schema_version": "3.0.0", + "domain_schema_version": "1.0.0", + "identity": {"name": "v3 user", "language": "fr"}, + "agent_instructions": "be concise", + } + envelope = save_klickd(v3_payload, PASSPHRASE) + recovered = load_klickd(envelope, passphrase=PASSPHRASE) + assert recovered == v3_payload + + +def test_v3_payload_fails_v4_strict_validation(): + """A v3 payload_schema_version MUST NOT validate against v4 strict (different enum).""" + v3_payload = {"payload_schema_version": "3.0.0"} + with pytest.raises(KlickdError) as exc: + validate(v3_payload, strict=True) + assert exc.value.code == KlickdErrorCode.SCHEMA + + +# -- Optional registered profiles (media/project/gaming) --------------------- + + +def test_gaming_profile_persona_validates(): + """RPG persona carries a gaming-style profile_kind; MUST validate strict.""" + persona = _load_persona("05-rpg-gamer-en.klickd") + validate(persona, strict=True, target="payload") + # And round-trip preserves any gaming-specific fields verbatim. + recovered = load_klickd(save_klickd(persona, PASSPHRASE), passphrase=PASSPHRASE) + assert recovered == persona + + +def test_creator_persona_validates(): + """Créateur média persona; MUST validate strict and round-trip.""" + persona = _load_persona("04-createur-media-fr.klickd") + validate(persona, strict=True, target="payload") + recovered = load_klickd(save_klickd(persona, PASSPHRASE), passphrase=PASSPHRASE) + assert recovered == persona + + +def test_project_chef_persona_validates(): + """Chef de projet PME persona (project.klickd-flavoured); MUST validate.""" + persona = _load_persona("02-chef-projet-pme-fr.klickd") + validate(persona, strict=True, target="payload") + + +# -- iter_errors variant ----------------------------------------------------- + + +def test_validate_iter_errors_empty_on_valid(): + persona = _load_persona(PERSONA_FILES[0]) + assert validate_iter_errors(persona, strict=True) == [] + + +def test_validate_iter_errors_returns_paths(): + bad = _minimal_strict_payload() + bad["verification_gates"] = {"public_post": "loud"} + errs = validate_iter_errors(bad, strict=True) + assert errs + # Each entry is (path, message) + for path, msg in errs: + assert isinstance(path, str) + assert isinstance(msg, str) + + +# -- Unknown-field preservation under validation (§33.7) --------------------- + + +def test_unknown_top_level_field_validates_and_roundtrips(): + """An unknown top-level field MUST validate (additionalProperties: true) + AND MUST round-trip verbatim (§33.7).""" + payload = _minimal_strict_payload() + payload["x_experimental_block"] = {"future": True, "values": [1, 2, 3]} + validate(payload, strict=True) + recovered = load_klickd(save_klickd(payload, PASSPHRASE), passphrase=PASSPHRASE) + assert recovered["x_experimental_block"] == payload["x_experimental_block"]