diff --git a/conformance/fixtures/valid/composition.kpack/PACK.yaml b/conformance/fixtures/valid/composition.kpack/PACK.yaml new file mode 100644 index 0000000..f0b0d25 --- /dev/null +++ b/conformance/fixtures/valid/composition.kpack/PACK.yaml @@ -0,0 +1,4 @@ +name: composition-fixture +version: 2026.04.18 +domain: test/conformance +author: KP Conformance Suite diff --git a/conformance/fixtures/valid/composition.kpack/claims.md b/conformance/fixtures/valid/composition.kpack/claims.md new file mode 100644 index 0000000..eb77ed1 --- /dev/null +++ b/conformance/fixtures/valid/composition.kpack/claims.md @@ -0,0 +1,23 @@ + +--- +pack: composition-fixture | v: 2026.04.18 | domain: test/conformance +confidence: simple | normalized +--- + +# Composition Fixture + +> Composition pack fixture. References other packs via `composition.yaml` — see `minimal-fixture` and `typical-fixture`. Evidence lives in those referenced packs, not here. This pack carries composition-context narrative only. + +## Composition Context + +This fixture exercises the composition-pack path in the conformance runner. It has `composition.yaml` present, no `evidence.md`, and only narrative content in `claims.md` — no dense claim bullets to cite evidence for. + +A composition pack SHOULD contain claim bullets when its composition context warrants them (e.g., a meeting pack with claims about the meeting itself). This fixture intentionally omits them to prove the minimum-shape case. diff --git a/conformance/fixtures/valid/composition.kpack/composition.yaml b/conformance/fixtures/valid/composition.kpack/composition.yaml new file mode 100644 index 0000000..a2502d2 --- /dev/null +++ b/conformance/fixtures/valid/composition.kpack/composition.yaml @@ -0,0 +1,25 @@ +type: meeting-prep + +meeting: + date: 2026-04-18T10:00:00-04:00 + duration: 30m + participants: + - Alice + - Bob + context: > + Conformance test fixture — demonstrates a valid composition pack + structure with no evidence.md. All knowledge lives in referenced + packs; this pack only declares the composition. + +agenda: + - topic: Minimal pack coverage + reference: minimal-fixture + talking_points: + - Confirm frontmatter parsing + - Walk through a single-claim pack + +pre_load: + - minimal-fixture + +on_demand: + - typical-fixture diff --git a/conformance/fixtures/valid/maximal.kpack/signatures.yaml b/conformance/fixtures/valid/maximal.kpack/signatures.yaml new file mode 100644 index 0000000..5248396 --- /dev/null +++ b/conformance/fixtures/valid/maximal.kpack/signatures.yaml @@ -0,0 +1,14 @@ +algorithm: SHA-256 +pack_id: "0b3c1f9a-4d7e-4a1c-9f2b-3e4a5b6c7d8e" +pack_name: maximal-fixture +pack_version: "2026.04.04-rev2" +pack_hash: "0000000000000000000000000000000000000000000000000000000000000000" +files: + PACK.yaml: "1111111111111111111111111111111111111111111111111111111111111111" + claims.md: "2222222222222222222222222222222222222222222222222222222222222222" + evidence.md: "3333333333333333333333333333333333333333333333333333333333333333" +sealed_at: "2026-04-18T10:00:00Z" +sealed_by: capture-client +parent: + version: "2026.04.04-rev1" + pack_hash: "4444444444444444444444444444444444444444444444444444444444444444" diff --git a/conformance/grammar/kp-signatures.schema.json b/conformance/grammar/kp-signatures.schema.json index f1aa575..87bc528 100644 --- a/conformance/grammar/kp-signatures.schema.json +++ b/conformance/grammar/kp-signatures.schema.json @@ -2,65 +2,111 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://kp.dev/schemas/kp-signatures-v1.json", "title": "KP:1 signatures.yaml Schema", - "description": "JSON Schema for the signatures.yaml integrity manifest in a KP:1 knowledge pack. Provides cryptographic hashes and optional signing for integrity verification.", + "description": "JSON Schema for the signatures.yaml integrity manifest in a KP:1 knowledge pack. Aligned with ARCHIVE.md §4 (v0.7.3+).", "type": "object", - "required": ["algorithm", "files"], + "required": ["algorithm", "pack_hash", "files", "sealed_at", "sealed_by"], "additionalProperties": false, "properties": { "algorithm": { "type": "string", - "enum": ["sha-256", "sha-384", "sha-512"], - "description": "Hash algorithm used for all file digests." + "enum": ["SHA-256"], + "description": "Hash algorithm. Currently only SHA-256 (future-proofed for upgrades)." }, - "signed_at": { + "pack_id": { "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp of when the hashes were computed and optionally signed." + "description": "Stable pack identity across versions. OPTIONAL in the schema for backward compatibility with pre-documentation archives; new archives SHOULD include it." + }, + "pack_name": { + "type": "string", + "description": "Optional self-describing pack name (equal to PACK.yaml name). Enables receipts and relay indexing without a second file read." + }, + "pack_version": { + "type": "string", + "description": "Optional self-describing pack version (equal to PACK.yaml version). Enables receipts without a second file read." + }, + "pack_hash": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Pack content hash computed per ARCHIVE.md §3 (lowercase hex SHA-256 digest, 64 characters)." }, "files": { "type": "object", "additionalProperties": { "type": "string", - "pattern": "^[a-f0-9]+$", - "description": "Hex-encoded content hash for the file." + "pattern": "^[a-f0-9]{64}$" + }, + "propertyNames": { + "not": { "const": "signatures.yaml" } }, "minProperties": 1, - "description": "Map of file paths (relative to pack root) to their hex-encoded content hashes. At minimum should include PACK.yaml and claims.md." + "description": "Map of NFC-normalized relative file paths (with / separator) to their lowercase hex SHA-256 digests. MUST NOT include the root-level signatures.yaml itself (enforced via propertyNames)." + }, + "sealed_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 UTC timestamp of sealing (e.g., 2026-04-12T14:30:00Z)." }, - "signing_key": { + "sealed_by": { + "type": "string", + "description": "Identifier of the sealing system (e.g., capture-client, analysis-service)." + }, + "parent": { "type": "object", - "required": ["type", "public_key"], + "required": ["version", "pack_hash"], "additionalProperties": false, "properties": { - "type": { + "version": { "type": "string", - "enum": ["ed25519", "ecdsa-p256", "rsa-4096"], - "description": "Key algorithm." + "description": "Parent pack's version string from its PACK.yaml." }, - "public_key": { + "pack_hash": { "type": "string", - "description": "Base64-encoded public key." + "pattern": "^[a-f0-9]{64}$", + "description": "Parent pack's pack_hash (lowercase hex)." }, - "key_id": { - "type": "string", - "description": "Optional key identifier for key management systems." + "merge_parents": { + "type": "array", + "items": { + "type": "object", + "required": ["version", "pack_hash"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "description": "Additional parent's version string." + }, + "pack_hash": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Additional parent's pack_hash (lowercase hex)." + } + } + }, + "description": "Additional parent references for branch-merge lineage. OPTIONAL; absent in single-parent archives." } }, - "description": "Signing key reference. Required when a signature is present." + "description": "Parent version reference. Absent for v1 of a pack; present for v2 and later." }, "signature": { - "type": "string", - "description": "Base64-encoded cryptographic signature over the canonical hash manifest." - } - }, - "allOf": [ - { - "if": { - "required": ["signature"] + "type": "object", + "required": ["method", "value", "key_id"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["ed25519", "hmac-sha256", "rsa-pss-sha256"], + "description": "Signing method identifier." + }, + "value": { + "type": "string", + "description": "Base64-encoded (standard, padded) signature over the signing payload defined in ARCHIVE.md §4.2." + }, + "key_id": { + "type": "string", + "description": "Opaque key identifier for locating the verification key." + } }, - "then": { - "required": ["signing_key", "signed_at"] - } + "description": "Optional digital signature. When present, all three sub-fields are REQUIRED." } - ] + } } diff --git a/conformance/run.py b/conformance/run.py index 720df06..7e4811c 100644 --- a/conformance/run.py +++ b/conformance/run.py @@ -39,6 +39,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent SCHEMA_PATH = SCRIPT_DIR / "grammar" / "kp-pack.schema.json" +SIGNATURES_SCHEMA_PATH = SCRIPT_DIR / "grammar" / "kp-signatures.schema.json" FIXTURES_DIR = SCRIPT_DIR / "fixtures" EXAMPLES_DIR = SCRIPT_DIR.parent / "examples" @@ -48,6 +49,7 @@ "verbose-claims.kpack", "mixed-syntax.kpack", "maximal.kpack", + "composition.kpack", ] INVALID_ORDER = [ "no-rosetta.kpack", @@ -79,6 +81,7 @@ } _schema_cache = None +_signatures_schema_cache = None def schema(): @@ -89,6 +92,14 @@ def schema(): return _schema_cache +def signatures_schema(): + global _signatures_schema_cache + if _signatures_schema_cache is None: + with open(SIGNATURES_SCHEMA_PATH) as f: + _signatures_schema_cache = json.load(f) + return _signatures_schema_cache + + def _stringify_dates(obj): """Convert datetime.date objects from PyYAML back to ISO strings.""" if isinstance(obj, (datetime.date, datetime.datetime)): @@ -135,7 +146,17 @@ def validate_pack(pack_dir: Path) -> list[Err]: pack_yaml_path = pack_dir / "PACK.yaml" claims_path = pack_dir / "claims.md" evidence_path = pack_dir / "evidence.md" - for p in (pack_yaml_path, claims_path, evidence_path): + composition_path = pack_dir / "composition.yaml" + signatures_path = pack_dir / "signatures.yaml" + + # Composition packs MAY omit evidence.md (SPEC.md §2, + # "Composition-pack File Requirements"). claims.md remains required. + is_composition = composition_path.exists() + required_files = [pack_yaml_path, claims_path] + if not is_composition: + required_files.append(evidence_path) + + for p in required_files: if not p.exists(): errs.append(Err("parse", f"missing {p.name}")) if errs: @@ -152,6 +173,25 @@ def validate_pack(pack_dir: Path) -> list[Err]: except jsonschema.ValidationError as e: errs.append(Err("schema", e.message)) + # ── signatures.yaml: validate against its schema (if present) ── + if signatures_path.exists(): + try: + sigs = _stringify_dates(yaml.safe_load(signatures_path.read_text())) + except yaml.YAMLError as e: + errs.append(Err("signatures", f"signatures.yaml parse error: {e}")) + else: + if sigs is None: + errs.append(Err("signatures", "signatures.yaml is empty")) + else: + try: + jsonschema.validate( + sigs, + signatures_schema(), + format_checker=jsonschema.FormatChecker(), + ) + except jsonschema.ValidationError as e: + errs.append(Err("signatures", f"signatures.yaml: {e.message}")) + # ── claims.md: Rosetta header ── text = claims_path.read_text() rosetta = re.match(r"