diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..cc029a1 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,63 @@ +--- +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: Validate Pace lock contract + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: "pace-validation-${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + validate: + name: Validate schema, example, implementation, and tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Harden runner + # step-security/harden-runner v2.21.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Checkout Pace + # actions/checkout v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Validate the complete example lock + run: >- + python3 scripts/validate_lock.py + examples/pace.lock.json + --as-of "2026-08-22T00:00:00Z" + + - name: Run adversarial validator tests + run: >- + python3 -m unittest discover + --start-directory tests + --pattern "test_*.py" + --verbose + + - name: Compile Python sources + run: python3 -m compileall -q scripts tests + + - name: Parse checked-in JSON + shell: bash + run: | + set -euo pipefail + while IFS= read -r document; do + python3 -m json.tool "${document}" >/dev/null + done < <(find examples schemas -type f -name "*.json" -print | sort) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 184adba..89b8135 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ status: provisional owners: - egohygiene created: 2026-08-19 -updated: 2026-08-19 +updated: 2026-08-21 governed_by: - architecture-architecture depends_on: @@ -60,6 +60,21 @@ flowchart LR The diagram is conceptual. [SYSTEM.md](SYSTEM.md) remains authoritative for responsibilities and implementation evidence determines current availability. +## Implemented lock-validation slice + +```text +schemas/pace-lock-v1.schema.json # closed desired-state contract +examples/pace.lock.json # six-kind conformance example +scripts/validate_lock.py # offline independent validator +tests/test_validate_lock.py # adversarial contract evidence +.github/workflows/validate.yml # least-privilege validation gate +``` + +The validator is deliberately independent from future source resolvers and +updaters. It reads one lock, validates immutable provenance, ownership, +compatibility, rollback, and exception time bounds, and emits evidence without +network or write capabilities. + ## Dependency rules - Sibling domain capabilities integrate through versioned public contracts, not direct access to internals. @@ -83,7 +98,10 @@ The architecture favors independently usable local and self-hosted operation. Op ## Evidence and uncertainty -- **Observed:** The repository README establishes the intended boundary as the repository adoption, reconciliation, synchronization, and conformance mechanism for the Ego Hygiene organization; significant implementation remains incomplete. +- **Observed:** Pace owns a versioned dependency-lock schema, six-kind example, + standalone offline validator, adversarial test suite, and least-privilege CI + gate. Update resolution, drift detection, planning, and application remain + unimplemented. - **Decided for this draft:** The repository owns the bounded concern described here and participates through versioned contracts. - **Proposed:** Target systems and later roadmap phases remain proposals until accepted and implemented. - **Open question:** Which parts of this draft should become active in the first independently versioned release? diff --git a/DECISIONS.md b/DECISIONS.md index 193e459..f9654b3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -8,7 +8,7 @@ status: provisional owners: - egohygiene created: 2026-08-19 -updated: 2026-08-19 +updated: 2026-08-21 governed_by: - architecture-decisions depends_on: @@ -40,6 +40,7 @@ Do not rewrite historical context to fit current understanding. Amend a record f - ADR-001: Separate detection, planning, and application - ADR-002: Preserve repository-local overrides as first-class records - ADR-003: Require reviewable changes for synchronization +- ADR-004: Make desired-state locks independently verifiable ## ADR-001: Separate detection, planning, and application @@ -68,9 +69,18 @@ Do not rewrite historical context to fit current understanding. Amend a record f - **Consequences:** The choice improves ownership and predictability while requiring maintained contracts, validation, and migration discipline. - **Reconsider when:** New evidence shows that the boundary prevents standalone usefulness, safety, portability, or maintainability. +## ADR-004: Make desired-state locks independently verifiable + +- **Status:** Accepted for the v1 lock contract +- **Date:** 2026-08-21 +- **Context:** A synchronization tool cannot be the sole authority asserting that its own update output is safe. Repositories need a portable record of source identity, content integrity, generated ownership, compatibility, rollback, and temporary exceptions before any updater receives write authority. +- **Decision:** Define a closed JSON lock contract covering foundation, Aether, workflow, container, site, and schema dependencies. Require immutable references and SHA-256 digests. Validate the lock with a standard-library-only tool that has no network, updater, or write capability. Treat exceptions as approved records with bounded expiry, never as integrity bypasses. +- **Consequences:** Desired state is reviewable and independently testable before drift or apply exists. The lock duplicates some upstream metadata, and later resolvers must prove that fetched bytes match it. Contract-breaking changes require a new lock schema major and migration. +- **Reconsider when:** A portable signed manifest standard can express the same repository ownership, compatibility, rollback, and exception semantics without weakening offline validation. + ## Open decisions -- Release and compatibility policy for the first stable version. +- Signing and transparency policy for future resolved locks and update plans. - Exact self-hosted, managed, and organization-integrated deployment boundaries. - Which target systems must exist before the architecture status may become active. diff --git a/LOCK_POLICY.md b/LOCK_POLICY.md new file mode 100644 index 0000000..dccfe30 --- /dev/null +++ b/LOCK_POLICY.md @@ -0,0 +1,130 @@ +# Pace lock, provenance, exception, and update policy + +## Purpose + +A Pace lock is a repository-owned record of the exact foundation, Aether, +workflow, container, site, and schema inputs a repository has adopted. It makes +desired state reviewable without giving Pace permission to update anything. + +The v1 contract is [`egohygiene.pace.lock/v1`](schemas/pace-lock-v1.schema.json). +Locks are JSON so standard review tools, JSON Schema tooling, and the standalone +validator can inspect the same closed representation. + +## Required provenance + +Every lock entry records: + +- a stable entry ID and one of the six supported dependency kinds; +- the source owner, locator, human version, immutable reference type, immutable + reference, and SHA-256 content digest; +- the target repository path, its owner, whether it is generated or + consumer-owned, and the generator when applicable; +- the compatibility contract and accepted major version; +- migration and rollback state; +- either no exception or one approved, tracked, time-bounded exception. + +Git sources use a full lowercase 40-character commit SHA. OCI sources use an +immutable `sha256:` manifest digest. Content-addressed site or schema inputs use +a `sha256:` reference. For OCI and content-addressed sources, the reference and +recorded digest must agree. A future resolver may additionally fetch and verify +bytes, but the independent validator performs no network calls and never trusts +an updater's result. + +## Generated ownership + +Target ownership controls what a future Pace apply phase may change: + +| Management | Generator | Required rollback | Meaning | +| --- | --- | --- | --- | +| `generated` | Required owner/repository identity | `restore-lock-and-generated-targets` | The named generator owns regeneration; reviewed rollback restores both the previous lock and generated target bytes | +| `consumer-owned` | Must be `null` | `restore-lock-only` | Pace records desired source state but never rewrites the target; the consumer owns migration and content rollback | + +A lock does not grant write authority. Detection, update planning, application, +verification, and publication remain separate operations. + +## Compatibility and migration + +Every entry names a contract ending in `/vMAJOR`; `accepted_major` must match. +Unknown contracts fail closed. Compatibility within a major does not imply that +an update is behaviorally safe—it means the source declares the same contract +family and may proceed to review and verification. + +Migration states are: + +- `not-required`: the selected source is compatible with the target state; +- `required`: migration work is known but incomplete, so validation fails and + the lock is not adoptable; +- `completed`: required migration evidence is included in the reviewed change. + +A major transition requires a new contract declaration, explicit migration, +and a reviewed lock update. Pace never silently widens a compatibility range. + +## Update sequence + +A future updater must preserve these boundaries: + +1. Read and independently validate the existing lock. +2. Resolve candidate source versions without modifying the repository. +3. Verify immutable references and content digests. +4. Compare declared contract majors and identify migration requirements. +5. Produce a deterministic plan containing lock and owned-target changes. +6. Open a reviewable pull request; never push directly to the default branch. +7. Run the standalone validator on the proposed lock independently of the + updater. +8. Regenerate only targets marked `generated` and owned by the named generator. +9. Verify resulting bytes and repository tests before merge. + +The v1 work implements steps 1 and 7. Resolution, planning, and application are +deliberately outside this issue. + +## Rollback + +The previous accepted lock is the rollback anchor. A failed or reverted update: + +- restores the previous lock document; +- restores previous bytes for targets whose management is `generated`; +- leaves consumer-owned target content under consumer control; +- reruns independent validation and repository verification; +- records the failed source version so automation does not immediately repeat + the same unsafe update. + +Rollback never replaces a lock with an unreviewed mutable reference. + +## Exceptions + +Exceptions are first-class lock records, not comments or hidden repository +settings. Each includes a unique `EXC-YYYY-NNN` ID, reason, approver, issue time, +expiry time, and HTTPS tracking URL. + +- Repository policy bounds exceptions to 1–90 days. +- An entry's expiry must be after issuance and within the repository maximum. +- Future and expired exceptions fail validation at the selected validation + instant. +- Exceptions never bypass JSON structure, immutable-reference, digest, + ownership, compatibility-major, or rollback requirements. +- Renewal is a new reviewed decision with a new bounded interval; expiry is not + extended silently. + +Use `--as-of` in tests and evidence pipelines for deterministic evaluation. +Normal local validation uses the current UTC instant. + +## Independent validation + +```bash +python3 scripts/validate_lock.py "path/to/pace.lock.json" + +python3 scripts/validate_lock.py \ + "path/to/pace.lock.json" \ + --as-of "2026-08-22T00:00:00Z" \ + --format "json" +``` + +The validator uses only the Python standard library. It rejects duplicate JSON +keys, unknown fields, malformed or mutable references, inconsistent digests, +ambiguous ownership, unsafe rollback declarations, duplicate IDs or targets, +unsorted entries, pending migrations, and invalid exception windows. + +Exit status `0` means the lock is valid at the evaluation instant. Status `1` +means the document could not be loaded or violates the lock contract. Argument +errors use the standard status `2`. JSON output uses +`egohygiene.pace.lock-validation/v1`. diff --git a/README.md b/README.md index 83270e9..861d315 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,36 @@ # pace + ⚡ Automation, CI/CD, adoption, reconciliation, and synchronization infrastructure for evolving repositories. + +Pace begins with a read-only, independently verifiable desired-state lock. The +`egohygiene.pace.lock/v1` contract records exact foundation, Aether, workflow, +container, site, and schema sources; their immutable references and SHA-256 +digests; target ownership; compatibility and migration state; rollback mode; +and bounded exceptions. + +## Validate a lock + +```bash +python3 scripts/validate_lock.py "examples/pace.lock.json" +``` + +The validator has no updater dependency, third-party Python packages, network +access, or write behavior. It validates the example, contract semantics, +generated ownership, and exception expiry independently. + +```bash +python3 scripts/validate_lock.py \ + "examples/pace.lock.json" \ + --as-of "2026-08-22T00:00:00Z" \ + --format "json" +``` + +See the complete [lock and update policy](LOCK_POLICY.md), the +[JSON Schema](schemas/pace-lock-v1.schema.json), and the +[six-kind example](examples/pace.lock.json). + +## Current authority boundary + +Pace v1 validates desired state only. It does not resolve updates, edit files, +open pull requests, or apply changes. Future detection, planning, application, +verification, and publication phases remain separate authority boundaries. diff --git a/ROADMAP.md b/ROADMAP.md index 3f970a2..be1ac12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ status: provisional owners: - egohygiene created: 2026-08-19 -updated: 2026-08-19 +updated: 2026-08-21 governed_by: - architecture-roadmap depends_on: @@ -32,6 +32,9 @@ This roadmap describes capability evolution, not promised dates or an issue queu ## Phase 1: Define desired and observed state schemas +**Status:** Desired dependency lock v1 is implemented and independently +validated. Observed-state and drift schemas remain planned for Phase 2. + **Outcome:** A bounded capability advances from documented intent to validated, independently usable behavior. **Exit signals:** @@ -104,7 +107,9 @@ Optional managed services, enterprise controls, marketplaces, and the conversati ## Evidence and uncertainty -- **Observed:** The repository README establishes the intended boundary as the repository adoption, reconciliation, synchronization, and conformance mechanism for the Ego Hygiene organization; significant implementation remains incomplete. +- **Observed:** Pace owns `egohygiene.pace.lock/v1`, an offline validator, + adversarial exception and provenance tests, a six-kind example, and a CI gate. + No updater or repository mutation path exists. - **Decided for this draft:** The repository owns the bounded concern described here and participates through versioned contracts. - **Proposed:** Target systems and later roadmap phases remain proposals until accepted and implemented. - **Open question:** Which parts of this draft should become active in the first independently versioned release? diff --git a/SYSTEM.md b/SYSTEM.md index 48f1bca..cd91e28 100644 --- a/SYSTEM.md +++ b/SYSTEM.md @@ -8,7 +8,7 @@ status: provisional owners: - egohygiene created: 2026-08-19 -updated: 2026-08-19 +updated: 2026-08-21 governed_by: - architecture-system depends_on: @@ -32,6 +32,8 @@ This document identifies Pace's logical systems and responsibilities. It answers | System | State | Responsibility | | --- | --- | --- | +| Desired-state lock contract | Active | Records immutable source identity, content digest, target ownership, compatibility, rollback, and bounded exceptions for six dependency kinds. | +| Independent lock validator | Active | Validates lock structure and semantics offline without trusting an updater or receiving write authority. | | Repository inventory reader | Target | Owns its bounded portion of the repository adoption, reconciliation, synchronization, and conformance mechanism for the Ego Hygiene organization; exposes explicit inputs, outputs, failure states, and evidence. | | Desired-state resolver | Target | Owns its bounded portion of the repository adoption, reconciliation, synchronization, and conformance mechanism for the Ego Hygiene organization; exposes explicit inputs, outputs, failure states, and evidence. | | Drift engine | Target | Owns its bounded portion of the repository adoption, reconciliation, synchronization, and conformance mechanism for the Ego Hygiene organization; exposes explicit inputs, outputs, failure states, and evidence. | @@ -61,7 +63,9 @@ Systems fail closed at destructive, publication, privacy, and security boundarie ## Evidence and uncertainty -- **Observed:** The repository README establishes the intended boundary as the repository adoption, reconciliation, synchronization, and conformance mechanism for the Ego Hygiene organization; significant implementation remains incomplete. +- **Observed:** Desired-state lock validation is active. Repository inventory, + drift, planning, application, override storage, and reporting remain target + systems. - **Decided for this draft:** The repository owns the bounded concern described here and participates through versioned contracts. - **Proposed:** Target systems and later roadmap phases remain proposals until accepted and implemented. - **Open question:** Which parts of this draft should become active in the first independently versioned release? diff --git a/examples/pace.lock.json b/examples/pace.lock.json new file mode 100644 index 0000000..ec0c00e --- /dev/null +++ b/examples/pace.lock.json @@ -0,0 +1,181 @@ +{ + "$schema": "../schemas/pace-lock-v1.schema.json", + "schema": "egohygiene.pace.lock/v1", + "repository": "example/consumer", + "policy": { + "unknown_contract": "fail-closed", + "update_mode": "reviewed-pull-request", + "max_exception_days": 30, + "rollback": "restore-previous-lock-and-owned-targets" + }, + "locks": [ + { + "id": "aether-agent-bundle", + "kind": "aether", + "source": { + "owner": "example/aether", + "locator": "https://example.invalid/aether", + "version": "v1.0.0", + "reference_type": "git-commit", + "reference": "1111111111111111111111111111111111111111", + "digest": { + "algorithm": "sha256", + "value": "1111111111111111111111111111111111111111111111111111111111111111" + } + }, + "target": { + "path": "AGENTS.md", + "management": "generated", + "owner": "example/consumer", + "generator": "example/holon" + }, + "compatibility": { + "contract": "example.aether.agent-bundle/v1", + "accepted_major": 1, + "migration": "not-required", + "rollback": "restore-lock-and-generated-targets" + }, + "exception": null + }, + { + "id": "container-development", + "kind": "container", + "source": { + "owner": "example/realm", + "locator": "oci://ghcr.io/example/realm/development", + "version": "v1.0.0", + "reference_type": "oci-digest", + "reference": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "digest": { + "algorithm": "sha256", + "value": "2222222222222222222222222222222222222222222222222222222222222222" + } + }, + "target": { + "path": ".devcontainer/devcontainer.json", + "management": "consumer-owned", + "owner": "example/consumer", + "generator": null + }, + "compatibility": { + "contract": "example.realm.development/v1", + "accepted_major": 1, + "migration": "not-required", + "rollback": "restore-lock-only" + }, + "exception": null + }, + { + "id": "foundation-repository", + "kind": "foundation", + "source": { + "owner": "example/hygiene", + "locator": "https://example.invalid/hygiene", + "version": "v1.0.0", + "reference_type": "git-commit", + "reference": "3333333333333333333333333333333333333333", + "digest": { + "algorithm": "sha256", + "value": "3333333333333333333333333333333333333333333333333333333333333333" + } + }, + "target": { + "path": ".github/foundation.json", + "management": "generated", + "owner": "example/consumer", + "generator": "example/holon" + }, + "compatibility": { + "contract": "example.hygiene.foundation/v1", + "accepted_major": 1, + "migration": "not-required", + "rollback": "restore-lock-and-generated-targets" + }, + "exception": null + }, + { + "id": "schema-repository-report", + "kind": "schema", + "source": { + "owner": "example/relay", + "locator": "https://example.invalid/relay/schema.json", + "version": "v1.0.0", + "reference_type": "content-digest", + "reference": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "digest": { + "algorithm": "sha256", + "value": "4444444444444444444444444444444444444444444444444444444444444444" + } + }, + "target": { + "path": "schemas/repository-report.schema.json", + "management": "consumer-owned", + "owner": "example/consumer", + "generator": null + }, + "compatibility": { + "contract": "example.relay.repository-report/v1", + "accepted_major": 1, + "migration": "not-required", + "rollback": "restore-lock-only" + }, + "exception": null + }, + { + "id": "site-documentation", + "kind": "site", + "source": { + "owner": "example/foundation", + "locator": "https://example.invalid/foundation", + "version": "v1.0.0", + "reference_type": "git-commit", + "reference": "5555555555555555555555555555555555555555", + "digest": { + "algorithm": "sha256", + "value": "5555555555555555555555555555555555555555555555555555555555555555" + } + }, + "target": { + "path": "site.config.json", + "management": "generated", + "owner": "example/consumer", + "generator": "example/holon" + }, + "compatibility": { + "contract": "example.foundation.site/v1", + "accepted_major": 1, + "migration": "not-required", + "rollback": "restore-lock-and-generated-targets" + }, + "exception": null + }, + { + "id": "workflow-repository-intelligence", + "kind": "workflow", + "source": { + "owner": "example/relay", + "locator": "https://example.invalid/relay", + "version": "v1.0.0", + "reference_type": "git-commit", + "reference": "6666666666666666666666666666666666666666", + "digest": { + "algorithm": "sha256", + "value": "6666666666666666666666666666666666666666666666666666666666666666" + } + }, + "target": { + "path": ".github/workflows/repository-intelligence.yml", + "management": "generated", + "owner": "example/consumer", + "generator": "example/holon" + }, + "compatibility": { + "contract": "example.relay.repository-intelligence/v1", + "accepted_major": 1, + "migration": "not-required", + "rollback": "restore-lock-and-generated-targets" + }, + "exception": null + } + ] +} diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..2fd3cc4 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,15 @@ +# Pace schemas + +| Contract | Schema identity | File | +| --- | --- | --- | +| Repository dependency lock | `egohygiene.pace.lock/v1` | [`pace-lock-v1.schema.json`](pace-lock-v1.schema.json) | + +Pace-owned contracts use the stable namespace: + +```text +https://egohygiene.github.io/pace/contracts//v/schema.json +``` + +Schema versions are independent from the Pace implementation version. A +breaking field or semantic change requires a new major schema and explicit +migration guidance; v1 never changes meaning in place. diff --git a/schemas/pace-lock-v1.schema.json b/schemas/pace-lock-v1.schema.json new file mode 100644 index 0000000..c4159ac --- /dev/null +++ b/schemas/pace-lock-v1.schema.json @@ -0,0 +1,198 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/pace/contracts/lock/v1/schema.json", + "title": "Pace repository dependency lock v1", + "$comment": "Cross-entry, time-window, digest-consistency, and compatibility-major semantics are enforced by scripts/validate_lock.py.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema", "repository", "policy", "locks"], + "properties": { + "$schema": { + "type": "string", + "pattern": "(^|/)pace-lock-v1.schema.json$" + }, + "schema": {"const": "egohygiene.pace.lock/v1"}, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "policy": {"$ref": "#/$defs/policy"}, + "locks": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/lock"} + } + }, + "$defs": { + "policy": { + "type": "object", + "additionalProperties": false, + "required": [ + "unknown_contract", + "update_mode", + "max_exception_days", + "rollback" + ], + "properties": { + "unknown_contract": {"const": "fail-closed"}, + "update_mode": {"const": "reviewed-pull-request"}, + "max_exception_days": {"type": "integer", "minimum": 1, "maximum": 90}, + "rollback": {"const": "restore-previous-lock-and-owned-targets"} + } + }, + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "value"], + "properties": { + "algorithm": {"const": "sha256"}, + "value": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "locator", + "version", + "reference_type", + "reference", + "digest" + ], + "properties": { + "owner": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "locator": {"type": "string", "pattern": "^(https|oci)://[^\\s]+$"}, + "version": {"type": "string", "minLength": 1}, + "reference_type": {"enum": ["git-commit", "oci-digest", "content-digest"]}, + "reference": {"type": "string", "minLength": 1}, + "digest": {"$ref": "#/$defs/digest"} + }, + "allOf": [ + { + "if": {"properties": {"reference_type": {"const": "git-commit"}}}, + "then": {"properties": {"reference": {"pattern": "^[0-9a-f]{40}$"}}} + }, + { + "if": { + "properties": { + "reference_type": {"enum": ["oci-digest", "content-digest"]} + } + }, + "then": { + "properties": {"reference": {"pattern": "^sha256:[0-9a-f]{64}$"}} + } + } + ] + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": ["path", "management", "owner", "generator"], + "properties": { + "path": { + "type": "string", + "pattern": "^(?!/)(?!.*//)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*\\\\)(?!.*\/$)[^\\u0000]+$" + }, + "management": {"enum": ["generated", "consumer-owned"]}, + "owner": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "generator": { + "oneOf": [ + {"type": "null"}, + {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"} + ] + } + }, + "allOf": [ + { + "if": {"properties": {"management": {"const": "generated"}}}, + "then": {"properties": {"generator": {"type": "string"}}} + }, + { + "if": {"properties": {"management": {"const": "consumer-owned"}}}, + "then": {"properties": {"generator": {"type": "null"}}} + } + ] + }, + "compatibility": { + "type": "object", + "additionalProperties": false, + "required": ["contract", "accepted_major", "migration", "rollback"], + "properties": { + "contract": {"type": "string", "pattern": "^[A-Za-z0-9._/-]+/v[1-9][0-9]*$"}, + "accepted_major": {"type": "integer", "minimum": 1}, + "migration": {"enum": ["not-required", "required", "completed"]}, + "rollback": { + "enum": ["restore-lock-only", "restore-lock-and-generated-targets"] + } + } + }, + "exception": { + "type": "object", + "additionalProperties": false, + "required": ["id", "reason", "approved_by", "issued_at", "expires_at", "tracking_url"], + "properties": { + "id": {"type": "string", "pattern": "^EXC-[0-9]{4}-[0-9]{3,}$"}, + "reason": {"type": "string", "minLength": 1}, + "approved_by": {"type": "string", "minLength": 1}, + "issued_at": {"type": "string", "format": "date-time"}, + "expires_at": {"type": "string", "format": "date-time"}, + "tracking_url": {"type": "string", "pattern": "^https://[^\\s]+$"} + } + }, + "lock": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "source", "target", "compatibility", "exception"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"}, + "kind": {"enum": ["foundation", "aether", "workflow", "container", "site", "schema"]}, + "source": {"$ref": "#/$defs/source"}, + "target": {"$ref": "#/$defs/target"}, + "compatibility": {"$ref": "#/$defs/compatibility"}, + "exception": { + "oneOf": [ + {"type": "null"}, + {"$ref": "#/$defs/exception"} + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "target": { + "properties": {"management": {"const": "generated"}} + } + } + }, + "then": { + "properties": { + "compatibility": { + "properties": { + "rollback": {"const": "restore-lock-and-generated-targets"} + } + } + } + } + }, + { + "if": { + "properties": { + "target": { + "properties": {"management": {"const": "consumer-owned"}} + } + } + }, + "then": { + "properties": { + "compatibility": { + "properties": {"rollback": {"const": "restore-lock-only"}} + } + } + } + } + ] + } + } +} diff --git a/scripts/validate_lock.py b/scripts/validate_lock.py new file mode 100755 index 0000000..50e89da --- /dev/null +++ b/scripts/validate_lock.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Validate a Pace v1 lock independently of any updater or network service.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import re +import sys +from typing import Any, Sequence + +LOCK_SCHEMA = "egohygiene.pace.lock/v1" +LOCK_SCHEMA_FILE = "pace-lock-v1.schema.json" +LOCK_KINDS = {"foundation", "aether", "workflow", "container", "site", "schema"} +REFERENCE_TYPES = {"git-commit", "oci-digest", "content-digest"} +MIGRATIONS = {"not-required", "required", "completed"} +REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +IDENTIFIER = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +EXCEPTION_ID = re.compile(r"^EXC-[0-9]{4}-[0-9]{3,}$") +SHA_1 = re.compile(r"^[0-9a-f]{40}$") +SHA_256 = re.compile(r"^[0-9a-f]{64}$") +DIGEST_REFERENCE = re.compile(r"^sha256:[0-9a-f]{64}$") +CONTRACT = re.compile(r"^[A-Za-z0-9._/-]+/v([1-9][0-9]*)$") +LOCATOR = re.compile(r"^(?:https|oci)://[^\s]+$") +HTTPS_URL = re.compile(r"^https://[^\s]+$") +UTC_TIMESTAMP = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$" +) + +ROOT_KEYS = {"$schema", "schema", "repository", "policy", "locks"} +POLICY_KEYS = {"unknown_contract", "update_mode", "max_exception_days", "rollback"} +LOCK_KEYS = {"id", "kind", "source", "target", "compatibility", "exception"} +SOURCE_KEYS = {"owner", "locator", "version", "reference_type", "reference", "digest"} +DIGEST_KEYS = {"algorithm", "value"} +TARGET_KEYS = {"path", "management", "owner", "generator"} +COMPATIBILITY_KEYS = {"contract", "accepted_major", "migration", "rollback"} +EXCEPTION_KEYS = { + "id", + "reason", + "approved_by", + "issued_at", + "expires_at", + "tracking_url", +} + + +class DuplicateKeyError(ValueError): + """Raised when JSON repeats an object key.""" + + +def object_without_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Construct a JSON object while refusing ambiguous duplicate keys.""" + + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise DuplicateKeyError(f"duplicate JSON key: {key}") + value[key] = item + return value + + +def load_lock(path: Path) -> dict[str, Any]: + """Load one UTF-8 lock object without resolving sources or targets.""" + + value = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=object_without_duplicates, + ) + if not isinstance(value, dict): + raise ValueError("lock document must be a JSON object") + return value + + +def parse_timestamp(value: Any, label: str, errors: list[str]) -> datetime | None: + """Parse one canonical UTC timestamp into an aware datetime.""" + + if not isinstance(value, str) or UTC_TIMESTAMP.fullmatch(value) is None: + errors.append(f"{label} must be an RFC 3339 UTC timestamp ending in Z") + return None + try: + parsed = datetime.fromisoformat(f"{value[:-1]}+00:00") + except ValueError: + errors.append(f"{label} is not a valid RFC 3339 timestamp") + return None + if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed): + errors.append(f"{label} must use UTC") + return None + return parsed + + +def require_object(value: Any, label: str, errors: list[str]) -> dict[str, Any] | None: + """Return a typed object or append one validation error.""" + + if not isinstance(value, dict): + errors.append(f"{label} must be an object") + return None + return value + + +def require_closed_keys( + value: dict[str, Any], expected: set[str], label: str, errors: list[str] +) -> None: + """Reject missing and unknown keys at a closed contract boundary.""" + + missing = sorted(expected - set(value)) + unknown = sorted(set(value) - expected) + if missing: + errors.append(f"{label} is missing keys: {', '.join(missing)}") + if unknown: + errors.append(f"{label} has unknown keys: {', '.join(unknown)}") + + +def valid_repository(value: Any) -> bool: + """Return whether a value is an owner/repository identifier.""" + + return isinstance(value, str) and REPOSITORY.fullmatch(value) is not None + + +def valid_target_path(value: Any) -> bool: + """Return whether a target is one normalized repository-relative path.""" + + if ( + not isinstance(value, str) + or not value + or value.startswith("/") + or value.endswith("/") + or "//" in value + or "\\" in value + or "\x00" in value + ): + return False + parts = value.split("/") + return bool(parts) and all(part not in {".", ".."} for part in parts) + + +def validate_source(source: Any, label: str, errors: list[str]) -> None: + """Validate immutable source identity and content provenance.""" + + value = require_object(source, label, errors) + if value is None: + return + require_closed_keys(value, SOURCE_KEYS, label, errors) + if not valid_repository(value.get("owner")): + errors.append(f"{label}.owner must be an owner/repository identifier") + locator = value.get("locator") + if not isinstance(locator, str) or LOCATOR.fullmatch(locator) is None: + errors.append(f"{label}.locator must use https:// or oci://") + if not isinstance(value.get("version"), str) or not value["version"].strip(): + errors.append(f"{label}.version must be non-empty") + reference_type = value.get("reference_type") + reference = value.get("reference") + if reference_type not in REFERENCE_TYPES: + errors.append(f"{label}.reference_type is unsupported: {reference_type}") + elif reference_type == "git-commit": + if not isinstance(reference, str) or SHA_1.fullmatch(reference) is None: + errors.append(f"{label}.reference must be a full lowercase Git commit SHA") + elif not isinstance(reference, str) or DIGEST_REFERENCE.fullmatch(reference) is None: + errors.append(f"{label}.reference must be a lowercase sha256 digest reference") + + digest = require_object(value.get("digest"), f"{label}.digest", errors) + if digest is None: + return + require_closed_keys(digest, DIGEST_KEYS, f"{label}.digest", errors) + if digest.get("algorithm") != "sha256": + errors.append(f"{label}.digest.algorithm must be sha256") + digest_value = digest.get("value") + if not isinstance(digest_value, str) or SHA_256.fullmatch(digest_value) is None: + errors.append(f"{label}.digest.value must be 64 lowercase hexadecimal characters") + if reference_type in {"oci-digest", "content-digest"} and isinstance(reference, str): + if reference.removeprefix("sha256:") != digest_value: + errors.append(f"{label}.reference and digest.value must agree") + + +def validate_target(target: Any, compatibility: Any, label: str, errors: list[str]) -> str | None: + """Validate target path, generated ownership, and rollback coupling.""" + + value = require_object(target, label, errors) + if value is None: + return None + require_closed_keys(value, TARGET_KEYS, label, errors) + path = value.get("path") + if not valid_target_path(path): + errors.append(f"{label}.path must be a normalized repository-relative path") + path = None + if not valid_repository(value.get("owner")): + errors.append(f"{label}.owner must be an owner/repository identifier") + management = value.get("management") + generator = value.get("generator") + if management == "generated": + if not valid_repository(generator): + errors.append(f"{label}.generator is required for generated targets") + expected_rollback = "restore-lock-and-generated-targets" + elif management == "consumer-owned": + if generator is not None: + errors.append(f"{label}.generator must be null for consumer-owned targets") + expected_rollback = "restore-lock-only" + else: + errors.append(f"{label}.management is unsupported: {management}") + expected_rollback = None + if isinstance(compatibility, dict) and expected_rollback is not None: + if compatibility.get("rollback") != expected_rollback: + errors.append( + f"{label} management requires compatibility.rollback={expected_rollback}" + ) + return path if isinstance(path, str) else None + + +def validate_compatibility(value: Any, label: str, errors: list[str]) -> None: + """Validate the compatibility, migration, and rollback declaration.""" + + compatibility = require_object(value, label, errors) + if compatibility is None: + return + require_closed_keys(compatibility, COMPATIBILITY_KEYS, label, errors) + contract = compatibility.get("contract") + match = CONTRACT.fullmatch(contract) if isinstance(contract, str) else None + if match is None: + errors.append(f"{label}.contract must end with an explicit /vMAJOR") + accepted_major = compatibility.get("accepted_major") + if ( + isinstance(accepted_major, bool) + or not isinstance(accepted_major, int) + or accepted_major < 1 + ): + errors.append(f"{label}.accepted_major must be a positive integer") + elif match is not None and int(match.group(1)) != accepted_major: + errors.append(f"{label}.accepted_major must match the contract major") + migration = compatibility.get("migration") + if migration not in MIGRATIONS: + errors.append(f"{label}.migration is unsupported: {migration}") + elif migration == "required": + errors.append(f"{label}.migration remains required; the lock is not adoptable") + if compatibility.get("rollback") not in { + "restore-lock-only", + "restore-lock-and-generated-targets", + }: + errors.append(f"{label}.rollback is unsupported") + + +def validate_exception( + value: Any, + label: str, + as_of: datetime, + max_days: int | None, + errors: list[str], +) -> None: + """Validate one bounded, approved, non-expired exception.""" + + if value is None: + return + exception = require_object(value, label, errors) + if exception is None: + return + require_closed_keys(exception, EXCEPTION_KEYS, label, errors) + identifier = exception.get("id") + if not isinstance(identifier, str) or EXCEPTION_ID.fullmatch(identifier) is None: + errors.append(f"{label}.id must use EXC-YYYY-NNN format") + for field in ("reason", "approved_by"): + if not isinstance(exception.get(field), str) or not exception[field].strip(): + errors.append(f"{label}.{field} must be non-empty") + tracking = exception.get("tracking_url") + if not isinstance(tracking, str) or HTTPS_URL.fullmatch(tracking) is None: + errors.append(f"{label}.tracking_url must use https://") + issued = parse_timestamp(exception.get("issued_at"), f"{label}.issued_at", errors) + expires = parse_timestamp(exception.get("expires_at"), f"{label}.expires_at", errors) + if issued is None or expires is None: + return + if expires <= issued: + errors.append(f"{label}.expires_at must be later than issued_at") + elif max_days is not None and (expires - issued).total_seconds() > max_days * 86400: + errors.append(f"{label} exceeds policy.max_exception_days") + if as_of < issued: + errors.append(f"{label} is not active yet at the validation instant") + if as_of >= expires: + errors.append(f"{label} expired at {exception['expires_at']}") + + +def validate_lock(document: Any, as_of: datetime) -> list[str]: + """Return every v1 lock violation without mutating state or using a network.""" + + errors: list[str] = [] + root = require_object(document, "lock document", errors) + if root is None: + return errors + require_closed_keys(root, ROOT_KEYS, "lock document", errors) + schema_path = root.get("$schema") + if not isinstance(schema_path, str) or not ( + schema_path == LOCK_SCHEMA_FILE or schema_path.endswith(f"/{LOCK_SCHEMA_FILE}") + ): + errors.append(f"lock document.$schema must reference {LOCK_SCHEMA_FILE}") + if root.get("schema") != LOCK_SCHEMA: + errors.append(f"lock document.schema must be {LOCK_SCHEMA}") + if not valid_repository(root.get("repository")): + errors.append("lock document.repository must be an owner/repository identifier") + + policy = require_object(root.get("policy"), "lock document.policy", errors) + max_days: int | None = None + if policy is not None: + require_closed_keys(policy, POLICY_KEYS, "lock document.policy", errors) + if policy.get("unknown_contract") != "fail-closed": + errors.append("lock document.policy.unknown_contract must be fail-closed") + if policy.get("update_mode") != "reviewed-pull-request": + errors.append("lock document.policy.update_mode must be reviewed-pull-request") + if policy.get("rollback") != "restore-previous-lock-and-owned-targets": + errors.append( + "lock document.policy.rollback must restore the previous lock and owned targets" + ) + candidate = policy.get("max_exception_days") + if ( + isinstance(candidate, bool) + or not isinstance(candidate, int) + or not 1 <= candidate <= 90 + ): + errors.append("lock document.policy.max_exception_days must be between 1 and 90") + else: + max_days = candidate + + locks = root.get("locks") + if not isinstance(locks, list) or not locks: + errors.append("lock document.locks must be a non-empty array") + return errors + identifiers: list[str] = [] + paths: set[str] = set() + for index, item in enumerate(locks): + label = f"lock document.locks[{index}]" + lock = require_object(item, label, errors) + if lock is None: + continue + require_closed_keys(lock, LOCK_KEYS, label, errors) + identifier = lock.get("id") + if not isinstance(identifier, str) or IDENTIFIER.fullmatch(identifier) is None: + errors.append(f"{label}.id is invalid") + else: + if identifier in identifiers: + errors.append(f"duplicate lock id: {identifier}") + identifiers.append(identifier) + if lock.get("kind") not in LOCK_KINDS: + errors.append(f"{label}.kind is unsupported: {lock.get('kind')}") + validate_source(lock.get("source"), f"{label}.source", errors) + compatibility = lock.get("compatibility") + validate_compatibility(compatibility, f"{label}.compatibility", errors) + path = validate_target(lock.get("target"), compatibility, f"{label}.target", errors) + if path is not None: + if path in paths: + errors.append(f"duplicate lock target path: {path}") + paths.add(path) + validate_exception(lock.get("exception"), f"{label}.exception", as_of, max_days, errors) + if identifiers != sorted(identifiers): + errors.append("lock entries must be sorted by id for deterministic review") + return errors + + +def parse_as_of(value: str) -> datetime: + """Parse a command-line validation instant or raise argparse's error type.""" + + errors: list[str] = [] + parsed = parse_timestamp(value, "--as-of", errors) + if parsed is None: + raise argparse.ArgumentTypeError(errors[0]) + return parsed + + +def build_parser() -> argparse.ArgumentParser: + """Build the standalone validator command contract.""" + + parser = argparse.ArgumentParser( + description="Validate a Pace v1 lock without contacting sources or running an updater." + ) + parser.add_argument("lock_file", type=Path, help="Path to a Pace JSON lock file.") + parser.add_argument( + "--as-of", + type=parse_as_of, + default=None, + help="Deterministic RFC 3339 UTC instant for exception evaluation.", + ) + parser.add_argument( + "--format", + choices=("human", "json"), + default="human", + help="Validation result format.", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run independent lock validation and return a stable process status.""" + + arguments = build_parser().parse_args(argv) + as_of = arguments.as_of or datetime.now(timezone.utc) + try: + document = load_lock(arguments.lock_file) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error: + errors = [f"could not load lock: {error}"] + else: + errors = validate_lock(document, as_of) + if arguments.format == "json": + print( + json.dumps( + { + "schema": "egohygiene.pace.lock-validation/v1", + "valid": not errors, + "lock": str(arguments.lock_file), + "as_of": as_of.isoformat().replace("+00:00", "Z"), + "errors": errors, + }, + indent=2, + sort_keys=True, + ) + ) + elif errors: + print(f"INVALID {arguments.lock_file}", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + else: + print(f"VALID {arguments.lock_file}") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_validate_lock.py b/tests/test_validate_lock.py new file mode 100644 index 0000000..12bc147 --- /dev/null +++ b/tests/test_validate_lock.py @@ -0,0 +1,190 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Adversarial evidence for the independent Pace lock validator.""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timezone +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +VALIDATOR_PATH = REPOSITORY_ROOT / "scripts/validate_lock.py" +EXAMPLE_PATH = REPOSITORY_ROOT / "examples/pace.lock.json" +SCHEMA_PATH = REPOSITORY_ROOT / "schemas/pace-lock-v1.schema.json" +SPEC = importlib.util.spec_from_file_location("validate_lock", VALIDATOR_PATH) +assert SPEC is not None +assert SPEC.loader is not None +validator = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validator) +AS_OF = datetime(2026, 8, 22, tzinfo=timezone.utc) + + +def example() -> dict[str, object]: + """Return a mutable copy of the checked-in valid lock.""" + + return json.loads(EXAMPLE_PATH.read_text(encoding="utf-8")) + + +class PaceLockTests(unittest.TestCase): + """Require immutable provenance and bounded exception semantics.""" + + def assert_error(self, document: object, fragment: str) -> None: + """Assert that one mutated lock produces a named violation.""" + + errors = validator.validate_lock(document, AS_OF) + self.assertTrue( + any(fragment in error for error in errors), + f"expected {fragment!r} in {errors!r}", + ) + + def test_example_covers_every_lock_kind_and_is_valid(self) -> None: + document = example() + + self.assertEqual(validator.validate_lock(document, AS_OF), []) + self.assertEqual( + {lock["kind"] for lock in document["locks"]}, + validator.LOCK_KINDS, + ) + + def test_schema_identity_matches_the_executable_contract(self) -> None: + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + self.assertEqual( + schema["$id"], + "https://egohygiene.github.io/pace/contracts/lock/v1/schema.json", + ) + self.assertEqual(schema["properties"]["schema"]["const"], validator.LOCK_SCHEMA) + self.assertEqual( + set(schema["$defs"]["lock"]["properties"]["kind"]["enum"]), + validator.LOCK_KINDS, + ) + + def test_source_references_and_digests_are_immutable_and_consistent(self) -> None: + invalid_commit = example() + invalid_commit["locks"][0]["source"]["reference"] = "main" + self.assert_error(invalid_commit, "full lowercase Git commit SHA") + + mismatched_digest = example() + mismatched_digest["locks"][1]["source"]["digest"]["value"] = "f" * 64 + self.assert_error(mismatched_digest, "reference and digest.value must agree") + + def test_generated_ownership_controls_generator_and_rollback(self) -> None: + missing_generator = example() + missing_generator["locks"][0]["target"]["generator"] = None + self.assert_error(missing_generator, "generator is required") + + unsafe_rollback = example() + unsafe_rollback["locks"][0]["compatibility"]["rollback"] = "restore-lock-only" + self.assert_error(unsafe_rollback, "restore-lock-and-generated-targets") + + def test_compatibility_major_and_migration_state_fail_closed(self) -> None: + wrong_major = example() + wrong_major["locks"][0]["compatibility"]["accepted_major"] = 2 + self.assert_error(wrong_major, "must match the contract major") + + pending_migration = example() + pending_migration["locks"][0]["compatibility"]["migration"] = "required" + self.assert_error(pending_migration, "lock is not adoptable") + + def test_active_bounded_exception_is_valid(self) -> None: + document = example() + document["locks"][0]["exception"] = { + "id": "EXC-2026-001", + "reason": "Temporary compatibility hold while the consumer migrates.", + "approved_by": "example/hygiene#1", + "issued_at": "2026-08-21T00:00:00Z", + "expires_at": "2026-09-01T00:00:00Z", + "tracking_url": "https://example.invalid/exceptions/1", + } + + self.assertEqual(validator.validate_lock(document, AS_OF), []) + + def test_expired_future_and_overlong_exceptions_are_rejected(self) -> None: + expired = example() + expired["locks"][0]["exception"] = { + "id": "EXC-2026-001", + "reason": "Expired fixture.", + "approved_by": "example/hygiene#1", + "issued_at": "2026-08-01T00:00:00Z", + "expires_at": "2026-08-21T00:00:00Z", + "tracking_url": "https://example.invalid/exceptions/1", + } + self.assert_error(expired, "expired at") + + future = deepcopy(expired) + future["locks"][0]["exception"]["issued_at"] = "2026-08-23T00:00:00Z" + future["locks"][0]["exception"]["expires_at"] = "2026-08-24T00:00:00Z" + self.assert_error(future, "not active yet") + + overlong = deepcopy(expired) + overlong["locks"][0]["exception"]["issued_at"] = "2026-08-01T00:00:00Z" + overlong["locks"][0]["exception"]["expires_at"] = "2026-10-01T00:00:00Z" + self.assert_error(overlong, "exceeds policy.max_exception_days") + + def test_paths_identifiers_order_and_unknown_fields_are_closed(self) -> None: + traversal = example() + traversal["locks"][0]["target"]["path"] = "../AGENTS.md" + self.assert_error(traversal, "normalized repository-relative path") + + duplicate = example() + duplicate["locks"][1]["id"] = duplicate["locks"][0]["id"] + self.assert_error(duplicate, "duplicate lock id") + + duplicate_target = example() + duplicate_target["locks"][1]["target"]["path"] = duplicate_target["locks"][0][ + "target" + ]["path"] + self.assert_error(duplicate_target, "duplicate lock target path") + + unsorted = example() + unsorted["locks"][0], unsorted["locks"][1] = ( + unsorted["locks"][1], + unsorted["locks"][0], + ) + self.assert_error(unsorted, "sorted by id") + + unknown = example() + unknown["locks"][0]["source"]["branch"] = "main" + self.assert_error(unknown, "unknown keys: branch") + + def test_duplicate_json_keys_are_rejected_before_validation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "duplicate.json" + path.write_text('{"schema":"one","schema":"two"}\n', encoding="utf-8") + + with self.assertRaisesRegex(validator.DuplicateKeyError, "duplicate JSON key"): + validator.load_lock(path) + + def test_cli_emits_stable_json_and_status(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(VALIDATOR_PATH), + str(EXAMPLE_PATH), + "--as-of", + "2026-08-22T00:00:00Z", + "--format", + "json", + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + document = json.loads(result.stdout) + self.assertEqual(document["schema"], "egohygiene.pace.lock-validation/v1") + self.assertTrue(document["valid"]) + self.assertEqual(document["errors"], []) + + +if __name__ == "__main__": + unittest.main()