From 3a39874c4bf375d627513312f2dbdc3c80544d0d Mon Sep 17 00:00:00 2001 From: "Vince C." Date: Sun, 24 May 2026 23:08:09 +0000 Subject: [PATCH] =?UTF-8?q?vectors(v4):=20P0-6=20=E2=80=94=20v4=20GA=20str?= =?UTF-8?q?ict=20cross-impl=20test=20vectors=20(Python=20+=20TypeScript)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the P0-6 deliverable from docs/roadmap/ROAD-TO-V4-GA.md: a strict v4 GA test-vector suite that runs identically under the Python cross-impl runner (verify_vectors.py) and the JS cross-impl runner (verify_vectors.mjs). Coexists with the preview suite — the v4.0.0-preview.1 vectors under tests/vectors_v40_preview.json are unchanged. New fixtures - tests/vectors_v40_ga.json — 6 positive cases: * 5 personas, one per profile_kind covered by examples/v4/personas/ (learner FR, team FR, agent EN, creator FR, learner+gaming EN); exercise verification_gates v1 (structured + flat-map), human_veto_policy, claim_sources v1 (prefer / require_citation_for), media_profile v1 (document + image + voice entries with blake3 hash + consent), and migration (RFC-004 v1 frozen fields). * 1 unified vector with encrypted=true to lock in the envelope-v3 contract retained in v4 per SPEC §33.10 #2 (kdf + cipher + ciphertext MUST be present). - tests/negative_vectors_v40_ga.json — 12 rejection cases, one per strict rule: * gate level outside the v1 frozen enum * payload_schema_version missing / outside the strict enum * media_profile v1 entry missing required hash / modality outside the v1 enum / hash.algo != blake3 * human_veto_policy.min_level outside the v1 enum * unified envelope encrypted=true missing kdf / cipher / ciphertext * klickd_version with an unsupported major * migration.migrated_at not RFC 3339 Z * gateEntry / human_veto_policy with an unknown field (additionalProperties: false) Runners - verify_vectors.py: new run_v40_ga_strict_suite() validates each document with jsonschema (Draft 2020-12) against either the strict payload schema (schemas/klickd-payload-v4.schema.json) or the strict unified schema (schema/klickd-v4.schema.json), then checks the structural assertions block. Negative vectors MUST raise at least one schema error. Skips cleanly with a clear message if jsonschema is not installed. - verify_vectors.mjs: new runV40GaStrictSuite() mirrors the Python runner. Positive vectors are checked structurally via the same assertions block; negative vectors are checked against a small set of hand-rolled rules keyed by failure_reason that reflect the strict schema (gate-level enum, media-modality enum, blake3 algo, RFC 3339 Z timestamp pattern, additionalProperties: false on frozen sub-objects, encrypted-envelope completeness). Ajv is not required at root: the canonical jsonschema validation is run by scripts/validate_v4_schemas.py and by the Python half of this runner; the JS half locks in the same shape via the rule mirror so both implementations fail the same negatives. Docs - docs/roadmap/ROAD-TO-V4-GA.md §P0-6: marked as PR candidate, with the exact fixture filenames and the list of frozen rules pinned. - SCHEMA_INDEX.md: pointer block under the v4 GA strict schemas section linking the new fixtures and runners and reaffirming that the preview vectors remain intact. Scope guardrails - No SDK version bump (Python and TypeScript packages remain at 4.0.0-preview.1). - No npm / PyPI / Zenodo publication. - No git tag, no GitHub release. - Preview vectors (tests/vectors_v40_preview.json) and all v2.5 / v3.0 vectors are unchanged. --- SCHEMA_INDEX.md | 6 + docs/roadmap/ROAD-TO-V4-GA.md | 9 +- tests/negative_vectors_v40_ga.json | 179 +++++++++++++++++ tests/vectors_v40_ga.json | 300 +++++++++++++++++++++++++++++ verify_vectors.mjs | 150 +++++++++++++++ verify_vectors.py | 113 +++++++++++ 6 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 tests/negative_vectors_v40_ga.json create mode 100644 tests/vectors_v40_ga.json diff --git a/SCHEMA_INDEX.md b/SCHEMA_INDEX.md index 67fbe74..11f7381 100644 --- a/SCHEMA_INDEX.md +++ b/SCHEMA_INDEX.md @@ -54,3 +54,9 @@ To unblock the GA track described in [`docs/roadmap/ROAD-TO-V4-GA.md` §P0-2](./ - **No SDK is bumped.** No npm / PyPI / Zenodo release is triggered. No git tag is created. **Validation:** see [`scripts/validate_v4_schemas.py`](./scripts/validate_v4_schemas.py) for the canonical local validation runner. + +**Cross-impl strict vectors (P0-6 — PR candidate):** + +- Positive: [`tests/vectors_v40_ga.json`](./tests/vectors_v40_ga.json) — 5 persona payloads (`profile_kind` learner/team/agent/creator/learner+gaming) covering the v1 frozen surface of `verification_gates` (structured + flat-map), `human_veto_policy`, `claim_sources`, `media_profile`, `migration`, plus one unified `encrypted=true` envelope (envelope-v3 contract retained per SPEC §33.10 #2). +- Negative: [`tests/negative_vectors_v40_ga.json`](./tests/negative_vectors_v40_ga.json) — 12 rejection cases pinning each frozen rule (gate-level enum, missing/unknown `payload_schema_version`, `media_profile` v1 hash/modality strictness, `human_veto_policy.min_level`, encrypted envelope completeness, `klickd_version` major range, `migration.migrated_at` RFC 3339 Z, `gateEntry` / `human_veto_policy` `additionalProperties: false`). +- Runners: [`verify_vectors.py`](./verify_vectors.py) (canonical `jsonschema` validation + structural assertions) and [`verify_vectors.mjs`](./verify_vectors.mjs) (structural rule checker mirroring the strict schema — no Ajv dep required at root). Both implementations validate the same fixtures and reject the same negatives without divergence. Preview vectors ([`tests/vectors_v40_preview.json`](./tests/vectors_v40_preview.json)) remain intact. diff --git a/docs/roadmap/ROAD-TO-V4-GA.md b/docs/roadmap/ROAD-TO-V4-GA.md index 651912e..0ad05e5 100644 --- a/docs/roadmap/ROAD-TO-V4-GA.md +++ b/docs/roadmap/ROAD-TO-V4-GA.md @@ -159,12 +159,19 @@ Chaque entrée précise : *Objet → Livrables → Critères de sortie (Definiti #### P0-6 — Vectors stricts + cross-impl - **Objet :** suite de vectors v4 *normative*, séparée de la suite preview, partagée Python ↔ JS, qui couvre toutes les sections v4 normées. -- **Livrables :** `tests/vectors_v40.json`, `tests/roundtrip_v40.json`, `tests/negative_vectors_v40.json`, CI `test-vectors` étendu. +- **Livrables :** `tests/vectors_v40_ga.json`, `tests/negative_vectors_v40_ga.json`, runners `verify_vectors.py` / `verify_vectors.mjs` étendus (suite `v4.0 GA strict` cross-impl). - **DoD :** - chaque MUST de SPEC v4 a au moins un vecteur positif et un vecteur négatif ; - exécutés par les deux SDKs sans divergence ; - les vectors preview ne disparaissent pas (preuve de continuité). - **Dépendances :** P0-2, P0-3, P0-4. +- **Statut (2026-05-24) :** **PR candidate.** Livrés : + [`tests/vectors_v40_ga.json`](../../tests/vectors_v40_ga.json) (6 positifs : 5 personas + 1 enveloppe chiffrée unifiée) et + [`tests/negative_vectors_v40_ga.json`](../../tests/negative_vectors_v40_ga.json) (12 négatifs : `gate_level` hors énum, `payload_schema_version` manquant/inconnu, `media_profile` v1 sans `hash` / `modality` hors énum / `hash.algo ≠ blake3`, + `human_veto_policy.min_level` invalide, enveloppe `encrypted=true` sans `kdf`/`cipher`/`ciphertext`, `klickd_version` hors range, `migration.migrated_at` non RFC 3339 Z, `gateEntry` avec champ inconnu, + `human_veto_policy` avec champ inconnu). Les vectors preview ([`tests/vectors_v40_preview.json`](../../tests/vectors_v40_preview.json)) restent inchangés (continuité). + Cross-impl : Python (`verify_vectors.py` — validation `jsonschema` stricte + assertions) **et** JS (`verify_vectors.mjs` — checker structurel reflétant les règles strictes, pas de dépendance Ajv requise au root) passent à 0 divergence. + **Aucun bump de version, aucun release, aucun publish.** ### 2.2 P1 — bloquant adoption diff --git a/tests/negative_vectors_v40_ga.json b/tests/negative_vectors_v40_ga.json new file mode 100644 index 0000000..5b2b0d7 --- /dev/null +++ b/tests/negative_vectors_v40_ga.json @@ -0,0 +1,179 @@ +{ + "description": ".klickd v4.0 GA strict NEGATIVE cross-impl test vectors. Each vector MUST FAIL strict v4 GA validation for the documented reason. Strict failures are detected either via JSON-Schema validation (Python: jsonschema; JS: structural rule check) or via structural assertions that match a documented rule. These vectors illustrate the strict-vs-preview boundary and pin down each rule in RFC-001 v1 / RFC-002 v1 / RFC-004 v1 frozen surface.", + "spec_version": "4.0", + "schema": { + "payload": "schemas/klickd-payload-v4.schema.json", + "unified": "schema/klickd-v4.schema.json" + }, + "notes": [ + "Each vector carries a 'document' that MUST fail the strict v4 GA schema for the documented reason.", + "'failure_reason' is a short tag both implementations check structurally (in addition to schema validation, where available).", + "'against' picks which schema applies: 'payload' (default) or 'unified'." + ], + "vectors": [ + { + "id": "v4.0-ga-neg-invalid-gate-level", + "description": "verification_gates uses a level outside the v1 frozen 5-enum (silent|warn|confirm|block|require-owner). RFC-002 v1 §6.", + "against": "payload", + "failure_reason": "gate_level_not_in_enum", + "document": { + "payload_schema_version": "4.0", + "verification_gates": { + "version": 1, + "gates": [ + {"action_class": "public_post", "level": "loud"} + ] + } + } + }, + { + "id": "v4.0-ga-neg-missing-payload-schema-version", + "description": "Strict v4 GA payload MUST declare payload_schema_version. Top-level required field missing.", + "against": "payload", + "failure_reason": "missing_required_payload_schema_version", + "document": { + "identity": {"display_name": "No Version"} + } + }, + { + "id": "v4.0-ga-neg-unsupported-payload-schema-version", + "description": "payload_schema_version must be one of {'4.0','4.0.0-preview.1'} (strict enum).", + "against": "payload", + "failure_reason": "payload_schema_version_not_in_enum", + "document": { + "payload_schema_version": "9.9" + } + }, + { + "id": "v4.0-ga-neg-media-entry-missing-hash", + "description": "media_profile v1 entry missing required 'hash' field (RFC-001 v1 §4 #3 — strict on v1 frozen fields id/modality/hash).", + "against": "payload", + "failure_reason": "media_entry_missing_hash", + "document": { + "payload_schema_version": "4.0", + "media_profile": { + "version": 1, + "entries": [ + {"id": "x", "modality": "voice"} + ] + } + } + }, + { + "id": "v4.0-ga-neg-media-modality-not-in-v1-enum", + "description": "media_profile v1 entry uses an unfrozen modality ('video'). v1 closed enum is {voice,image,document,embedding} — custom modalities live under x_* namespaces (RFC-001 v1 §4 #3).", + "against": "payload", + "failure_reason": "media_modality_not_in_v1_enum", + "document": { + "payload_schema_version": "4.0", + "media_profile": { + "version": 1, + "entries": [ + { + "id": "y", + "modality": "video", + "hash": {"algo": "blake3", "value": "b3:placeholder"} + } + ] + } + } + }, + { + "id": "v4.0-ga-neg-media-hash-algo-not-blake3", + "description": "media_profile v1 hash.algo MUST be 'blake3' (RFC-001 v1 §4 #2 — one hash, one algorithm).", + "against": "payload", + "failure_reason": "media_hash_algo_not_blake3", + "document": { + "payload_schema_version": "4.0", + "media_profile": { + "version": 1, + "entries": [ + { + "id": "z", + "modality": "image", + "hash": {"algo": "sha256", "value": "deadbeef"} + } + ] + } + } + }, + { + "id": "v4.0-ga-neg-human-veto-min-level-invalid", + "description": "human_veto_policy.min_level MUST be a valid gate level. 'panic' is not in the v1 frozen enum.", + "against": "payload", + "failure_reason": "human_veto_min_level_not_in_enum", + "document": { + "payload_schema_version": "4.0", + "human_veto_policy": { + "applies_to": ["public_post"], + "second_party": null, + "min_level": "panic" + } + } + }, + { + "id": "v4.0-ga-neg-encrypted-missing-kdf-cipher-ciphertext", + "description": "Unified schema: encrypted=true MUST carry kdf + cipher + ciphertext (envelope-v3 contract preserved in v4 per §33.10 #2).", + "against": "unified", + "failure_reason": "encrypted_missing_envelope_fields", + "document": { + "klickd_version": "4.0", + "created_at": "2026-05-24T00:00:00Z", + "encrypted": true + } + }, + { + "id": "v4.0-ga-neg-unified-bad-klickd-version", + "description": "Unified schema: klickd_version must match ^(3|4)\\.\\d+(...)$. '5.0' is not yet a known wire version.", + "against": "unified", + "failure_reason": "klickd_version_unsupported_major", + "document": { + "klickd_version": "5.0", + "created_at": "2026-05-24T00:00:00Z", + "encrypted": false + } + }, + { + "id": "v4.0-ga-neg-migration-bad-timestamp", + "description": "migration.migrated_at MUST be RFC 3339 Z-suffix (matches v3 envelope strict contract). 'yesterday' is not a valid timestamp.", + "against": "payload", + "failure_reason": "migration_migrated_at_not_rfc3339_z", + "document": { + "payload_schema_version": "4.0", + "migration": { + "source_version": "3.5.1", + "migrated_at": "yesterday" + } + } + }, + { + "id": "v4.0-ga-neg-gate-entry-extra-field", + "description": "gateEntry uses additionalProperties:false. Adding an unknown field ('priority') MUST fail strict validation.", + "against": "payload", + "failure_reason": "gate_entry_additional_property", + "document": { + "payload_schema_version": "4.0", + "verification_gates": { + "version": 1, + "gates": [ + {"action_class": "public_post", "level": "block", "priority": "high"} + ] + } + } + }, + { + "id": "v4.0-ga-neg-human-veto-additional-property", + "description": "human_veto_policy is strict (additionalProperties:false). 'unsafe_qr_target' as a free-form key is rejected.", + "against": "payload", + "failure_reason": "human_veto_additional_property", + "document": { + "payload_schema_version": "4.0", + "human_veto_policy": { + "applies_to": ["public_post"], + "min_level": "require-owner", + "unsafe_qr_target": "https://example.com/x" + } + } + } + ] +} diff --git a/tests/vectors_v40_ga.json b/tests/vectors_v40_ga.json new file mode 100644 index 0000000..3b80dbc --- /dev/null +++ b/tests/vectors_v40_ga.json @@ -0,0 +1,300 @@ +{ + "description": ".klickd v4.0 GA strict cross-impl test vectors (positive + negative). These vectors are STRUCTURAL: they exercise the strict v4 GA schema surface (verification_gates v1, human_veto_policy, claim_sources v1, media_profile v1, migration v1 frozen fields, unified envelope encrypted=true contract). They are run unencrypted (encrypted=false) so both the Python and the TypeScript cross-impl runners can validate them without invoking AES/Argon2. v4 encrypted-wire crypto is identical to v3 (SPEC §33.10 #2) and is already covered by vectors_v40_preview.json.", + "spec_version": "4.0", + "schema": { + "payload": "schemas/klickd-payload-v4.schema.json", + "unified": "schema/klickd-v4.schema.json" + }, + "notes": [ + "GA strict track — schemas/klickd-payload-v4.schema.json + schema/klickd-v4.schema.json.", + "Coexists with v4.0.0-preview.1 vectors (kept intact under vectors_v40_preview.json).", + "Each vector carries a 'document' (the .klickd JSON to validate) and an 'assertions' block describing what BOTH the Python and JS cross-impl runners MUST verify structurally.", + "Positive vectors MUST pass the strict v4 GA payload schema (and the unified schema when applicable).", + "Negative vectors MUST FAIL strict v4 GA validation for the documented reason. They illustrate the strict-vs-preview boundary." + ], + "vectors": [ + { + "id": "v4.0-ga-persona-learner-fr", + "description": "Persona 1 — Élève de Terminale (FR/LU). Mirrors examples/v4/personas/01-eleve-terminale-fr.klickd. profile_kind=learner; verification_gates v1 structured form; human_veto_policy with applies_to + rationale.", + "expected_behavior": "schema_valid", + "document": { + "payload_schema_version": "4.0", + "domain_schema_version": "education-1.0", + "profile_kind": "learner", + "identity": { + "display_name": "Élève Exemple", + "language": "fr", + "timezone": "Europe/Luxembourg" + }, + "verification_gates": { + "version": 1, + "user_default": "silent", + "gates": [ + {"id": "exam-claim", "action_class": "factual_claim_with_date", "level": "confirm", "reason": "Dates examen — vérifier."}, + {"id": "public-post", "action_class": "public_post", "level": "block", "reason": "Aucune publication publique."} + ] + }, + "human_veto_policy": { + "applies_to": ["public_post", "identity_assertion"], + "second_party": null, + "min_level": "block", + "rationale": "Profil potentiellement mineur — rien ne sort sans validation." + }, + "claim_sources": { + "prefer": ["user_supplied", "tool:web_search"], + "require_citation_for": ["factual_claim_with_date"] + }, + "agent_instructions": "Mode socratique — ne jamais donner la réponse directement." + }, + "assertions": { + "payload_schema_version": "4.0", + "profile_kind": "learner", + "verification_gates.version": 1, + "verification_gates.gates.length": 2, + "human_veto_policy.min_level": "block", + "claim_sources.prefer.0": "user_supplied" + } + }, + { + "id": "v4.0-ga-persona-team-fr", + "description": "Persona 2 — Chef de projet PME (FR). profile_kind=team; verification_gates flat-map form; claim_sources strict v1 fields.", + "expected_behavior": "schema_valid", + "document": { + "payload_schema_version": "4.0", + "domain_schema_version": "work-1.0", + "profile_kind": "team", + "identity": { + "display_name": "Chef de Projet Exemple", + "language": "fr", + "timezone": "Europe/Paris" + }, + "verification_gates": { + "factual_claim_with_date": "confirm", + "external_send": "require-owner", + "public_post": "block", + "schedule_change": "warn", + "internal_note": "silent" + }, + "human_veto_policy": { + "applies_to": ["external_send", "public_post"], + "second_party": "co_lead", + "min_level": "require-owner", + "rationale": "Toute communication externe doit être validée." + }, + "claim_sources": { + "prefer": ["tool:doi_resolver", "tool:web_search", "user_supplied"], + "require_citation_for": ["factual_claim_with_date", "regulation_reference"] + }, + "agent_instructions": "Toujours proposer brouillon avant envoi externe." + }, + "assertions": { + "payload_schema_version": "4.0", + "profile_kind": "team", + "verification_gates.external_send": "require-owner", + "human_veto_policy.second_party": "co_lead", + "claim_sources.require_citation_for.length": 2 + } + }, + { + "id": "v4.0-ga-persona-agent-en", + "description": "Persona 3 — Fullstack developer (EN). profile_kind=agent; media_profile v1 strict shape with one document entry (hash+blake3); migration block (RFC-004 v1 frozen fields).", + "expected_behavior": "schema_valid", + "document": { + "payload_schema_version": "4.0", + "domain_schema_version": "work-1.0", + "profile_kind": "agent", + "identity": { + "display_name": "Fullstack Dev Example", + "language": "en", + "timezone": "Europe/Luxembourg" + }, + "verification_gates": { + "version": 1, + "user_default": "silent", + "gates": [ + {"action_class": "code_push_to_main", "level": "require-owner", "reason": "Direct main pushes blocked."}, + {"action_class": "factual_claim_with_date", "level": "confirm"} + ] + }, + "human_veto_policy": { + "applies_to": ["code_push_to_main", "secret_rotation"], + "second_party": null, + "min_level": "require-owner", + "rationale": "Owner-only on push-to-main and secret rotation." + }, + "claim_sources": { + "prefer": ["tool:web_search", "user_supplied"], + "require_citation_for": ["regulation_reference"] + }, + "media_profile": { + "version": 1, + "entries": [ + { + "id": "spec-snapshot-2026-05", + "modality": "document", + "label": "SPEC.md snapshot", + "language": "en", + "media_type": "text/markdown", + "byte_size": 119640, + "hash": {"algo": "blake3", "value": "b3:placeholder-ga-strict-document-hash"}, + "consent": {"purposes": ["context"], "revocable": true} + } + ] + }, + "migration": { + "source_version": "3.5.1", + "migrated_at": "2026-05-24T09:00:00Z", + "migration_report_ref": "migration-reports/abc.json", + "backup_ref": "backups/learner-2026-05-24.klickd" + }, + "agent_instructions": "Owner-only on push-to-main." + }, + "assertions": { + "payload_schema_version": "4.0", + "profile_kind": "agent", + "media_profile.version": 1, + "media_profile.entries.0.modality": "document", + "media_profile.entries.0.hash.algo": "blake3", + "migration.source_version": "3.5.1" + } + }, + { + "id": "v4.0-ga-persona-creator-fr", + "description": "Persona 4 — Créateur média (FR). profile_kind=creator; media_profile v1 with image + voice entries; human_veto_policy on public_post.", + "expected_behavior": "schema_valid", + "document": { + "payload_schema_version": "4.0", + "domain_schema_version": "creator-1.0", + "profile_kind": "creator", + "identity": { + "display_name": "Créateur Exemple", + "language": "fr", + "timezone": "Europe/Luxembourg" + }, + "verification_gates": { + "version": 1, + "user_default": "warn", + "gates": [ + {"action_class": "public_post", "level": "require-owner", "reason": "Toute publication publique passe par l'auteur."}, + {"action_class": "ai_generated_voice_publish", "level": "block", "reason": "Voix générée ne sort jamais sans validation."} + ] + }, + "human_veto_policy": { + "applies_to": ["public_post", "ai_generated_voice_publish"], + "second_party": null, + "min_level": "require-owner", + "rationale": "Diffusion publique = veto humain obligatoire." + }, + "claim_sources": { + "prefer": ["user_supplied"], + "require_citation_for": ["factual_claim_with_date"] + }, + "media_profile": { + "version": 1, + "entries": [ + { + "id": "thumb-2026-05-24", + "modality": "image", + "label": "Thumbnail draft", + "media_type": "image/png", + "byte_size": 4096, + "hash": {"algo": "blake3", "value": "b3:placeholder-ga-strict-image-hash"}, + "consent": {"purposes": ["draft"], "revocable": true} + }, + { + "id": "voice-sample-fr", + "modality": "voice", + "label": "French voice reference (sample)", + "language": "fr", + "media_type": "audio/wav", + "duration_ms": 12000, + "hash": {"algo": "blake3", "value": "b3:placeholder-ga-strict-voice-hash"}, + "consent": {"purposes": ["voice_clone_reference"], "expires_at": "2027-05-24T00:00:00Z", "revocable": true} + } + ] + }, + "agent_instructions": "Aucune voix générée ne sort sans validation explicite." + }, + "assertions": { + "payload_schema_version": "4.0", + "profile_kind": "creator", + "media_profile.entries.length": 2, + "media_profile.entries.1.modality": "voice", + "human_veto_policy.applies_to.length": 2 + } + }, + { + "id": "v4.0-ga-persona-rpg-gamer-en", + "description": "Persona 5 — RPG gamer (EN). profile_kind=learner with gaming_profile permissive block; verification_gates structured; claim_sources with empty require_citation_for is also valid.", + "expected_behavior": "schema_valid", + "document": { + "payload_schema_version": "4.0", + "domain_schema_version": "gaming-1.0", + "profile_kind": "learner", + "identity": { + "display_name": "RPG Player Example", + "language": "en", + "timezone": "America/New_York" + }, + "verification_gates": { + "version": 1, + "user_default": "silent", + "gates": [ + {"action_class": "spoiler_share", "level": "confirm", "reason": "Avoid leaking story beats."}, + {"action_class": "real_money_purchase", "level": "block", "reason": "No real-money purchase suggestions."} + ] + }, + "human_veto_policy": { + "applies_to": ["real_money_purchase"], + "second_party": null, + "min_level": "block", + "rationale": "No real-money purchases proposed without explicit consent." + }, + "claim_sources": { + "prefer": ["user_supplied"], + "require_citation_for": [] + }, + "gaming_profile": { + "preferred_genres": ["jrpg", "tactics"], + "spoiler_tolerance": "low", + "difficulty": "story" + }, + "agent_instructions": "Never propose real-money purchases." + }, + "assertions": { + "payload_schema_version": "4.0", + "profile_kind": "learner", + "gaming_profile.spoiler_tolerance": "low", + "verification_gates.gates.1.level": "block", + "claim_sources.require_citation_for.length": 0 + } + }, + { + "id": "v4.0-ga-unified-encrypted-envelope", + "description": "Unified-schema vector: encrypted=true envelope MUST carry kdf + cipher + ciphertext (envelope-v3 contract retained per SPEC §33.10 #2). Uses the v3-compatible envelope crypto.", + "expected_behavior": "schema_valid_unified", + "document": { + "klickd_version": "4.0", + "created_at": "2026-05-24T09:00:00Z", + "encrypted": true, + "domain": "education", + "kdf": { + "name": "argon2id", + "params": {"m": 65536, "t": 3, "p": 1}, + "salt": "AAECAwQFBgcICQoLDA0ODw==" + }, + "cipher": { + "name": "AES-256-GCM", + "iv": "AAECAwQFBgcICQoL" + }, + "ciphertext": "AAECAwQFBgcICQoLDA0ODw==" + }, + "assertions": { + "klickd_version": "4.0", + "encrypted": true, + "kdf.name": "argon2id", + "cipher.name": "AES-256-GCM" + } + } + ] +} diff --git a/verify_vectors.mjs b/verify_vectors.mjs index 6fe31d4..47caa5c 100644 --- a/verify_vectors.mjs +++ b/verify_vectors.mjs @@ -574,6 +574,156 @@ for (const { path, label } of suites) { totalSkipped += skipped; } +// ── v4.0 GA strict suite — schema-validation cross-impl (P0-6) ────────────── +// Positive vectors: structural assertions must hold (each `assertions` entry is +// a dotted path → expected value, with `.length` suffix supported for arrays). +// Negative vectors: a hand-rolled rule checker mirrors the strict v4 GA schema +// rules referenced by `failure_reason` so this runner does not require Ajv. +// The canonical strict-schema validation (jsonschema in Python) is run by +// scripts/validate_v4_schemas.py and by the Python half of this test runner. +const GA_GATE_LEVELS = new Set(['silent', 'warn', 'confirm', 'block', 'require-owner']); +const GA_MEDIA_MODALITIES = new Set(['voice', 'image', 'document', 'embedding']); +const GA_PAYLOAD_SCHEMA_VERSIONS = new Set(['4.0', '4.0.0-preview.1']); +const RFC3339_Z_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; +const GA_KLICKD_VERSION_RE = /^(3|4)\.\d+(\.[0-9A-Za-z-.]+)?$/; +const GATE_ENTRY_KEYS = new Set(['id', 'action_class', 'level', 'reason']); +const HUMAN_VETO_KEYS = new Set(['applies_to', 'second_party', 'min_level', 'rationale']); + +function gaGetPath(doc, dotted) { + let cur = doc; + for (const seg of dotted.split('.')) { + if (cur === null || cur === undefined) return undefined; + if (Array.isArray(cur)) { + const idx = Number(seg); + if (!Number.isInteger(idx)) return undefined; + cur = cur[idx]; + } else if (typeof cur === 'object') { + cur = cur[seg]; + } else { + return undefined; + } + } + return cur; +} + +function gaCheckNegative(doc, against, reason) { + // Returns true if the document violates the documented rule. + switch (reason) { + case 'gate_level_not_in_enum': { + const vg = doc.verification_gates; + if (!vg) return false; + const entries = Array.isArray(vg.gates) ? vg.gates : Object.entries(vg).map(([k, v]) => ({ action_class: k, level: v })); + return entries.some(g => g && typeof g === 'object' && g.level !== undefined && !GA_GATE_LEVELS.has(g.level)); + } + case 'missing_required_payload_schema_version': + return !('payload_schema_version' in doc); + case 'payload_schema_version_not_in_enum': + return 'payload_schema_version' in doc && !GA_PAYLOAD_SCHEMA_VERSIONS.has(doc.payload_schema_version); + case 'media_entry_missing_hash': + return Array.isArray(doc?.media_profile?.entries) && + doc.media_profile.entries.some(e => e && typeof e === 'object' && !('hash' in e)); + case 'media_modality_not_in_v1_enum': + return Array.isArray(doc?.media_profile?.entries) && + doc.media_profile.entries.some(e => e?.modality && !GA_MEDIA_MODALITIES.has(e.modality)); + case 'media_hash_algo_not_blake3': + return Array.isArray(doc?.media_profile?.entries) && + doc.media_profile.entries.some(e => e?.hash && e.hash.algo !== 'blake3'); + case 'human_veto_min_level_not_in_enum': + return doc?.human_veto_policy?.min_level !== undefined && + !GA_GATE_LEVELS.has(doc.human_veto_policy.min_level); + case 'encrypted_missing_envelope_fields': + return against === 'unified' && doc.encrypted === true && + (!('kdf' in doc) || !('cipher' in doc) || !('ciphertext' in doc)); + case 'klickd_version_unsupported_major': + return against === 'unified' && typeof doc.klickd_version === 'string' && + !GA_KLICKD_VERSION_RE.test(doc.klickd_version); + case 'migration_migrated_at_not_rfc3339_z': + return doc?.migration?.migrated_at !== undefined && + !RFC3339_Z_RE.test(doc.migration.migrated_at); + case 'gate_entry_additional_property': { + const gates = doc?.verification_gates?.gates; + if (!Array.isArray(gates)) return false; + return gates.some(g => g && typeof g === 'object' && Object.keys(g).some(k => !GATE_ENTRY_KEYS.has(k))); + } + case 'human_veto_additional_property': { + const hv = doc?.human_veto_policy; + if (!hv || typeof hv !== 'object') return false; + return Object.keys(hv).some(k => !HUMAN_VETO_KEYS.has(k)); + } + default: + return false; + } +} + +async function runV40GaStrictSuite(posPath, negPath, label) { + let passed = 0, failed = 0; + console.log(`\n── ${label} ──────────────────────────`); + + if (existsSync(posPath)) { + const pos = JSON.parse(readFileSync(posPath, 'utf8')); + console.log(` spec ${pos.spec_version} — POSITIVE vectors`); + for (const v of pos.vectors) { + const vid = v.id; + const doc = v.document; + const mismatched = []; + for (const [path, expected] of Object.entries(v.assertions ?? {})) { + let actual; + if (path.endsWith('.length')) { + const base = gaGetPath(doc, path.slice(0, -'.length'.length)); + actual = (Array.isArray(base) || typeof base === 'string') ? base.length + : (base && typeof base === 'object') ? Object.keys(base).length + : undefined; + } else { + actual = gaGetPath(doc, path); + } + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + mismatched.push({ path, expected, actual }); + } + } + if (mismatched.length > 0) { + console.log(` FAIL ${vid}: assertion mismatch ${JSON.stringify(mismatched.slice(0, 3))}`); + failed++; + } else { + console.log(` PASS ${vid}: ${Object.keys(v.assertions ?? {}).length} assertion(s) OK`); + passed++; + } + } + } else { + console.log(' [SKIP] positive vectors file not found'); + } + + if (existsSync(negPath)) { + const neg = JSON.parse(readFileSync(negPath, 'utf8')); + console.log(` spec ${neg.spec_version} — NEGATIVE vectors`); + for (const v of neg.vectors) { + const vid = v.id; + const violated = gaCheckNegative(v.document, v.against ?? 'payload', v.failure_reason); + if (violated) { + console.log(` PASS ${vid}: rule violation detected as expected (${v.failure_reason})`); + passed++; + } else { + console.log(` FAIL ${vid}: expected rule violation (${v.failure_reason}), none detected`); + failed++; + } + } + } else { + console.log(' [SKIP] negative vectors file not found'); + } + + return { passed, failed, skipped: 0 }; +} + +{ + const { passed, failed, skipped } = await runV40GaStrictSuite( + join(__dir, 'tests/vectors_v40_ga.json'), + join(__dir, 'tests/negative_vectors_v40_ga.json'), + 'v4.0 GA strict vectors — cross-impl (P0-6)', + ); + totalPassed += passed; + totalFailed += failed; + totalSkipped += skipped; +} + const total = totalPassed + totalFailed; console.log('\n' + '='.repeat(50)); if (totalSkipped > 0) { diff --git a/verify_vectors.py b/verify_vectors.py index 2fe33e7..ba2f309 100644 --- a/verify_vectors.py +++ b/verify_vectors.py @@ -318,6 +318,119 @@ def run_v40_preview_suite() -> tuple[int, int]: total_failed += v4_f +# ── v4 GA strict suite — schema-validation cross-impl (P0-6) ───────────────── +def _get_path(doc, dotted_path): + """Tiny JSON pointer-ish helper: 'a.b.0.c' navigates dicts and lists.""" + cur = doc + for seg in dotted_path.split("."): + if isinstance(cur, list): + try: + cur = cur[int(seg)] + except (ValueError, IndexError): + return _MISSING + elif isinstance(cur, dict): + if seg not in cur: + return _MISSING + cur = cur[seg] + else: + return _MISSING + return cur + + +_MISSING = object() + + +def run_v40_ga_strict_suite() -> tuple[int, int]: + """ + Verify v4.0 GA strict vectors (P0-6). + + Each vector is a JSON document validated against either the strict payload + schema (schemas/klickd-payload-v4.schema.json) or the strict unified schema + (schema/klickd-v4.schema.json), plus a structural assertions block both + Python and JS implementations check identically. + + Positive vectors MUST validate successfully and match all assertions. + Negative vectors MUST FAIL strict validation (jsonschema errors > 0). + """ + pos_file = VECTORS_DIR / "vectors_v40_ga.json" + neg_file = VECTORS_DIR / "negative_vectors_v40_ga.json" + if not pos_file.exists() and not neg_file.exists(): + print("\n[SKIP] v4.0 GA strict suite — vector files not found") + return 0, 0 + + try: + from jsonschema import Draft202012Validator, RefResolver + except ImportError: + print("\n[SKIP] v4.0 GA strict suite — jsonschema not installed (pip install jsonschema)") + return 0, 0 + + repo = Path(__file__).parent + payload_schema = json.loads((repo / "schemas" / "klickd-payload-v4.schema.json").read_text()) + unified_schema = json.loads((repo / "schema" / "klickd-v4.schema.json").read_text()) + store = {payload_schema["$id"]: payload_schema, unified_schema["$id"]: unified_schema} + resolver = RefResolver.from_schema(unified_schema, store=store) + payload_validator = Draft202012Validator(payload_schema) + unified_validator = Draft202012Validator(unified_schema, resolver=resolver) + + passed = failed = 0 + + # Positive vectors + if pos_file.exists(): + pos = json.loads(pos_file.read_text()) + print(f"\n── v4.0 GA strict POSITIVE vectors — spec {pos['spec_version']} (P0-6) ──────────────────────────") + for v in pos["vectors"]: + vid = v["id"] + doc = v["document"] + behavior = v.get("expected_behavior", "schema_valid") + against = "unified" if "unified" in behavior else "payload" + validator = unified_validator if against == "unified" else payload_validator + errs = list(validator.iter_errors(doc)) + if errs: + print(f" FAIL {vid}: expected schema_valid, got {len(errs)} error(s); first: {errs[0].message[:160]}") + failed += 1 + continue + # Structural assertions + mismatched = [] + for path, expected in (v.get("assertions") or {}).items(): + actual = _get_path(doc, path) + if path.endswith(".length"): + base = _get_path(doc, path[: -len(".length")]) + actual = len(base) if isinstance(base, (list, str, dict)) else None + if actual != expected: + mismatched.append((path, expected, actual)) + if mismatched: + print(f" FAIL {vid}: assertion mismatch {mismatched[:3]}") + failed += 1 + else: + print(f" PASS {vid}: strict {against} OK + {len(v.get('assertions') or {})} assertion(s)") + passed += 1 + + # Negative vectors + if neg_file.exists(): + neg = json.loads(neg_file.read_text()) + print(f"\n── v4.0 GA strict NEGATIVE vectors — spec {neg['spec_version']} (P0-6) ──────────────────────────") + for v in neg["vectors"]: + vid = v["id"] + doc = v["document"] + against = v.get("against", "payload") + validator = unified_validator if against == "unified" else payload_validator + errs = list(validator.iter_errors(doc)) + reason = v.get("failure_reason", "?") + if errs: + print(f" PASS {vid}: strict {against} rejected as expected ({reason}; {len(errs)} err)") + passed += 1 + else: + print(f" FAIL {vid}: expected rejection ({reason}), document validated OK") + failed += 1 + + return passed, failed + + +v4ga_p, v4ga_f = run_v40_ga_strict_suite() +total_passed += v4ga_p +total_failed += v4ga_f + + total = total_passed + total_failed print(f"\n{'='*50}") print(f"TOTAL: {total_passed}/{total} passed ({total_failed} failed)")