From 57c7111cbc1bdeba32635e938a48fdf41bfeea0b Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 28 Jul 2026 18:47:29 +0200 Subject: [PATCH] Implement TREV-WP-0002 Stage 0 foundation: schemas, pure fold, golden Phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers the offline runnable specification foundation for the Trust Layer (TSD §3-§6), not a hosted Trust Service: - JSON Schemas for Phase Manifest, Ledger Entry, Extension Contract, and Conversion Attestation, encoding the Stage 0 working defaults (Q3 future license enum, Q6 single-currency Phases, Q8 required longstop_at). - src/target_revenue: pure Outstanding Target fold, SHA-256 hash-chain verification, Ed25519 signing helpers, extension conformance checks (including a core-term-redefinition heuristic), and conversion detection that never requires an attestation document to determine conversion status. - examples/phase-001: golden Phase package matching the concept doc's worked example, generated via scripts/generate_golden_phase.py so the hash chain is computed by the library itself, not hand-typed. - 32 passing pytest tests covering manifest/ledger/extension conformance, tamper/reorder detection, and the full lifecycle fold to conversion. - docs/adr/ADR-0001: proposed (not accepted) Stage 0 stack choice, per the WP-0002-T01 human-accept gate — implementation proceeded against the proposal as the workplan note permits, but the task stays open. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 7 + README.md | 23 ++- docs/adr/ADR-0001-stage0-library-stack.md | 128 +++++++++++++ examples/phase-001/attestation.json | 10 + .../extensions/cost-plus-operations.json | 24 +++ .../extensions/development-license.json | 24 +++ .../extensions/phase-sponsorship.json | 23 +++ .../service-with-development-allocation.json | 24 +++ examples/phase-001/ledger.json | 86 +++++++++ examples/phase-001/manifest.json | 32 ++++ pyproject.toml | 26 +++ schemas/conversion_attestation.schema.json | 52 ++++++ schemas/extension_contract.schema.json | 92 +++++++++ schemas/ledger_entry.schema.json | 108 +++++++++++ schemas/phase_manifest.schema.json | 108 +++++++++++ scripts/generate_golden_phase.py | 128 +++++++++++++ src/target_revenue/__init__.py | 14 ++ src/target_revenue/conversion.py | 80 ++++++++ src/target_revenue/fold.py | 109 +++++++++++ src/target_revenue/hashing.py | 75 ++++++++ src/target_revenue/validation.py | 176 ++++++++++++++++++ tests/conftest.py | 28 +++ tests/fixtures/non_conforming_extension.json | 24 +++ tests/test_conversion.py | 52 ++++++ tests/test_extension_conformance.py | 55 ++++++ tests/test_ledger_fold.py | 133 +++++++++++++ tests/test_manifest_validation.py | 61 ++++++ .../TREV-WP-0002-trust-service-foundation.md | 56 +++++- 28 files changed, 1751 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 docs/adr/ADR-0001-stage0-library-stack.md create mode 100644 examples/phase-001/attestation.json create mode 100644 examples/phase-001/extensions/cost-plus-operations.json create mode 100644 examples/phase-001/extensions/development-license.json create mode 100644 examples/phase-001/extensions/phase-sponsorship.json create mode 100644 examples/phase-001/extensions/service-with-development-allocation.json create mode 100644 examples/phase-001/ledger.json create mode 100644 examples/phase-001/manifest.json create mode 100644 pyproject.toml create mode 100644 schemas/conversion_attestation.schema.json create mode 100644 schemas/extension_contract.schema.json create mode 100644 schemas/ledger_entry.schema.json create mode 100644 schemas/phase_manifest.schema.json create mode 100644 scripts/generate_golden_phase.py create mode 100644 src/target_revenue/__init__.py create mode 100644 src/target_revenue/conversion.py create mode 100644 src/target_revenue/fold.py create mode 100644 src/target_revenue/hashing.py create mode 100644 src/target_revenue/validation.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/non_conforming_extension.json create mode 100644 tests/test_conversion.py create mode 100644 tests/test_extension_conformance.py create mode 100644 tests/test_ledger_fold.py create mode 100644 tests/test_manifest_validation.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..801f218 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +build/ +dist/ +.venv/ diff --git a/README.md b/README.md index bf4ea4c..a9ae0c5 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,10 @@ Program assessment: [`history/260728-SWOT-Assessment.md`](history/260728-SWOT-As | `specs/` | Product/tech specs, working defaults, future normative extracts | | `workplans/` | Active delivery plans | | `history/` | Dated non-normative exploration and assessments | -| `examples/` | Golden Phase packages (planned; WP-0002) | -| `schemas/` | Machine-readable schemas (planned; WP-0002) | +| `examples/` | Golden Phase packages (`phase-001`, from WP-0002) | +| `schemas/` | Machine-readable JSON Schemas (WP-0002) | +| `src/target_revenue/` | Pure Python validators, hash chain, Outstanding Target fold, conversion detection (WP-0002) | +| `docs/adr/` | Architecture decisions; ADR-0001 (Stage 0 library stack) is **proposed**, pending human accept | **`spec/` vs `specs/`:** The concept document predates the plural `specs/` directory and remains under `spec/` so history and cross-references stay stable. New product/tech artifacts go under `specs/`. Do not move the concept file without a deliberate migration note. @@ -53,6 +55,23 @@ Program assessment: [`history/260728-SWOT-Assessment.md`](history/260728-SWOT-As Hub index: [`WORK-RECORDS.md`](WORK-RECORDS.md) · brief: [`.custodian-brief.md`](.custodian-brief.md) +## Stage 0 library (schemas, pure fold, validators) + +WP-0002 delivers an **offline, dependency-light library** — not a hosted +Trust Service (`SCOPE.md` §3). It validates the schemas in `schemas/`, +computes the pure Outstanding Target fold, verifies the SHA-256 hash chain, +and checks Monetization Extension conformance. + +```bash +pip install -e ".[dev]" +python3 -m pytest tests/ # 32 tests, no network required +python3 scripts/generate_golden_phase.py # regenerate examples/phase-001/ledger.json +``` + +The implementation stack (Python, `jsonschema`, `pytest`, `hatchling`) is +proposed in [`docs/adr/ADR-0001-stage0-library-stack.md`](docs/adr/ADR-0001-stage0-library-stack.md) +and is **not yet accepted** — see `CONTRIBUTING.md` human decision gates. + ## What this repo does not claim yet - Final legal license text or enforceable TRSL diff --git a/docs/adr/ADR-0001-stage0-library-stack.md b/docs/adr/ADR-0001-stage0-library-stack.md new file mode 100644 index 0000000..00801ed --- /dev/null +++ b/docs/adr/ADR-0001-stage0-library-stack.md @@ -0,0 +1,128 @@ +--- +id: ADR-0001 +title: "Stage 0 library stack: schemas, pure fold, and validators" +status: proposed +date: 2026-07-28 +decided_by: null +workstream: TREV-WP-0002 (trust-service-foundation) +alternatives_considered: [TypeScript/Node, Go] +--- + +# ADR-0001 — Stage 0 Library Stack (Schemas, Pure Fold, Validators) + +## Status + +**Proposed.** Per `CONTRIBUTING.md` "Human decision gates" and +`workplans/TREV-WP-0002-trust-service-foundation.md` T01, this ADR locks +implementation technology for the repository and **must not be treated as +accepted without explicit human sign-off**. Agents may implement against +this proposal, but WP-0002-T01 stays `todo` (not `done`) until a maintainer +accepts or revises it. + +## Scope + +This decision covers **only** the Stage 0 deliverables of WP-0002: + +- JSON Schema (or equivalent) definitions for Phase Manifest, Target Ledger + Entry, Extension Contract, Transaction Allocation, Conversion Attestation; +- a pure, deterministic Outstanding Target fold; +- offline conformance validators; +- a golden Phase package and its test suite. + +It explicitly does **not** cover: hosted Phase/Extension Registry services, +multi-tenant ledger APIs, a metrics product, or a federation protocol +(`SCOPE.md` §3–§4). Those require a separate, later ADR once a Trust Service +implementation workplan exists. + +## Context + +`specs/TechnicalSpecificationDocument.md` §10 is deliberately non-binding on +language, runtime, and storage. Something has to be chosen to write the +Stage 0 library, but the choice should be cheap to revisit later since the +hosted Trust Service is a different, larger decision. + +Requirements pulled from TSD §3, §6 and `specs/OpenQuestions-WorkingDefaults.md` +Q14: + +- deterministic, pure computation (no hidden state, no floating-point order + sensitivity) for the Outstanding Target fold; +- JSON-Schema-shaped validation that a non-Python tool could later + reimplement against the same `.schema.json` files; +- SHA-256 canonical-serialization hash chaining and Ed25519 signatures over + the same canonical bytes; +- a test suite runnable offline, with no network services. + +## Decision + +**Python 3.11+, `jsonschema` for schema validation, dataclasses for typed +domain objects, `pytest` for the conformance suite, `hatchling` src-layout +packaging.** + +Canonical serialization for hashing: JSON with sorted keys, no insignificant +whitespace, UTF-8 encoding (`json.dumps(..., sort_keys=True, +separators=(",", ":"))`), hashed with SHA-256. Signatures: Ed25519 via +`cryptography` or `PyNaCl`, over the same canonical bytes. + +## Rationale + +| What Stage 0 needs | Python fit | +|---|---| +| JSON Schema authoring and validation | `jsonschema` is a direct, widely-used implementation | +| Pure fold over ledger entries | Trivial with dataclasses + `functools.reduce`; no framework needed | +| Canonical serialization + hashing | Stdlib `json` + `hashlib` sufficient | +| Ed25519 signing for examples | `cryptography` covers this without custom crypto code | +| Offline conformance test suite | `pytest` is the de facto standard; matches this workspace's other Python repos (e.g. `shard-wiki`) | +| Low ceremony for a schema/fold library, not a service | Python's scripting ergonomics outweigh static-typing benefits at this scope | + +### Why not TypeScript/Node + +Would be a reasonable alternative if the primary consumer were browser-based +tooling (cf. `binect-js`), but Stage 0 has no UI deliverable — it is a +schema + pure-function library consumed by CLI/tests. Node adds packaging +overhead (npm registry, `package.json` versioning discipline) without a +corresponding benefit here. + +### Why not Go + +Go would fit well if this were becoming a long-lived, standalone, +performance-sensitive service (cf. `key-cape` ADR-0001's reasoning) — but +Stage 0 is explicitly **not** the hosted Trust Service. A statically-typed, +compiled toolchain is more ceremony than a schema/fold/test library needs at +this stage. Revisit for the later hosted-service ADR. + +## Consequences + +### Positive + +- Fast path to a working, testable schema + fold library. +- `jsonschema` files are directly reusable by a future non-Python + implementation (hosted service ADR is unconstrained by this choice). +- Matches existing Python conventions in this workspace (pytest, hatchling + src-layout) for reviewer familiarity. + +### Negative / risks + +- Python's dynamic typing means domain-model discipline must be enforced by + convention (dataclasses + docstrings + tests), not the compiler. +- Should not be read as a decision about the hosted Trust Service's stack — + that is explicitly deferred and must not be assumed to inherit this choice + without its own ADR. + +### Compensating guardrails + +1. Typed domain objects (`dataclasses`, `frozen=True` where the schema marks + a field immutable) — no raw dicts crossing function boundaries in + `fold.py` or `conversion.py`. +2. All schema validation goes through the `.schema.json` files under + `schemas/` — no ad hoc field checks duplicated in Python without a + corresponding schema entry. +3. The fold function must be pure: same `(manifest, entries)` input always + produces the same Outstanding Target output, with no I/O. + +## Revisit trigger + +Reconsider this decision when a hosted Trust Service implementation +workplan is opened (`SCOPE.md` §3 "Production Trust Service"). That +decision should weigh multi-tenant hosting, storage, and operational +concerns not in scope here, and may reasonably choose a different stack +without invalidating this one. diff --git a/examples/phase-001/attestation.json b/examples/phase-001/attestation.json new file mode 100644 index 0000000..e21e377 --- /dev/null +++ b/examples/phase-001/attestation.json @@ -0,0 +1,10 @@ +{ + "phase": "trsl:phase:example-001", + "milestone_release": "release-1.0", + "conversion_timestamp": "2027-06-01T00:00:00Z", + "future_license": "MIT", + "final_development_credit": 67000.0, + "final_remission_credit": 33000.0, + "final_outstanding_target": 0.0, + "ledger_checkpoint": "trsl:entry:example0010006" +} diff --git a/examples/phase-001/extensions/cost-plus-operations.json b/examples/phase-001/extensions/cost-plus-operations.json new file mode 100644 index 0000000..5e52793 --- /dev/null +++ b/examples/phase-001/extensions/cost-plus-operations.json @@ -0,0 +1,24 @@ +{ + "id": "trsl:extension:cost-plus-operations", + "version": "1.0", + "value": { + "description": "Hosted and monitored operation of the software: compute, storage, networking, monitoring, backup, and incident response." + }, + "pricing": { + "method": "cost-plus, default 50% surcharge over eligible operations cost" + }, + "allocation": { + "rule": "Development allocation defaults to 0% of the operations price. A Phase may explicitly declare a nonzero development_allocation on the surcharge portion; absent that declaration, none of this revenue counts toward the Phase target.", + "default_rate": 0.0 + }, + "recognition": { + "event": "payment-settled" + }, + "reversal": { + "rule": "Refunds reverse proportionally; chargebacks reverse fully via a compensating credit-reversal entry." + }, + "evidence": { + "requirement": "Settled payment reference; eligible operations cost breakdown recommended for disputed amounts." + }, + "status": "registered" +} diff --git a/examples/phase-001/extensions/development-license.json b/examples/phase-001/extensions/development-license.json new file mode 100644 index 0000000..ff979a0 --- /dev/null +++ b/examples/phase-001/extensions/development-license.json @@ -0,0 +1,24 @@ +{ + "id": "trsl:extension:development-license", + "version": "1.0", + "value": { + "description": "Commercial-use rights and early commercial access to the protected Milestone Release." + }, + "pricing": { + "method": "fixed-fee-per-entitlement" + }, + "allocation": { + "rule": "100% of the collected development fee, net of tax, refunds, and chargebacks, becomes Development Credit for the identified Phase.", + "default_rate": 1.0 + }, + "recognition": { + "event": "payment-settled" + }, + "reversal": { + "rule": "Refunds and chargebacks generate a compensating credit-reversal ledger entry for the reversed amount." + }, + "evidence": { + "requirement": "Settled payment reference (processor transaction id) at minimum (working default Q10 tier E1); contract and invoice recommended for high-value credits (E2)." + }, + "status": "registered" +} diff --git a/examples/phase-001/extensions/phase-sponsorship.json b/examples/phase-001/extensions/phase-sponsorship.json new file mode 100644 index 0000000..26d1198 --- /dev/null +++ b/examples/phase-001/extensions/phase-sponsorship.json @@ -0,0 +1,23 @@ +{ + "id": "trsl:extension:phase-sponsorship", + "version": "1.0", + "value": { + "description": "Financial support explicitly directed at a named Phase, rather than the project generally." + }, + "pricing": { + "method": "declared-amount, negotiated per sponsorship agreement" + }, + "allocation": { + "rule": "The sponsoring party and the licensor explicitly declare the development_allocation fraction of the sponsorship amount at the time the sponsorship is recorded. Unrestricted general project sponsorship is not assigned to any Phase by default." + }, + "recognition": { + "event": "payment-settled" + }, + "reversal": { + "rule": "A withdrawn or refunded sponsorship generates a compensating credit-reversal entry for the previously declared development_allocation." + }, + "evidence": { + "requirement": "Sponsorship agreement or written declaration stating the target Phase and declared allocation, plus settled payment reference." + }, + "status": "registered" +} diff --git a/examples/phase-001/extensions/service-with-development-allocation.json b/examples/phase-001/extensions/service-with-development-allocation.json new file mode 100644 index 0000000..388cad0 --- /dev/null +++ b/examples/phase-001/extensions/service-with-development-allocation.json @@ -0,0 +1,24 @@ +{ + "id": "trsl:extension:service-with-development-allocation", + "version": "1.0", + "value": { + "description": "Installation, migration, configuration, integration, customization, support, or training service engagement." + }, + "pricing": { + "method": "time-and-materials or fixed-fee, per service agreement" + }, + "allocation": { + "rule": "Development allocation defaults to 0%. When a reusable deliverable from the engagement is incorporated into the governed Milestone Release, the reusable portion is explicitly split out and declared as development_allocation; customer-specific configuration is never allocated.", + "default_rate": 0.0 + }, + "recognition": { + "event": "payment-settled" + }, + "reversal": { + "rule": "Refunds and chargebacks generate a compensating credit-reversal entry for the reversed amount." + }, + "evidence": { + "requirement": "Settled payment reference; a statement identifying the reusable deliverable is required whenever a nonzero allocation is claimed." + }, + "status": "registered" +} diff --git a/examples/phase-001/ledger.json b/examples/phase-001/ledger.json new file mode 100644 index 0000000..f377d8c --- /dev/null +++ b/examples/phase-001/ledger.json @@ -0,0 +1,86 @@ +[ + { + "id": "trsl:entry:example0010001", + "phase": "trsl:phase:example-001", + "type": "development-credit", + "amount": 25000, + "currency": "USD", + "recognized_at": "2026-09-01T09:00:00Z", + "extension": { + "id": "trsl:extension:development-license", + "version": "1.0" + }, + "evidence_reference": "confidential:evidence:example-001-dc-0001", + "previous_entry_hash": "GENESIS" + }, + { + "id": "trsl:entry:example0010002", + "phase": "trsl:phase:example-001", + "type": "development-credit", + "amount": 10000, + "currency": "USD", + "recognized_at": "2026-09-15T09:00:00Z", + "extension": { + "id": "trsl:extension:phase-sponsorship", + "version": "1.0" + }, + "evidence_reference": "confidential:evidence:example-001-dc-0002", + "previous_entry_hash": "5a67c0dc3407de30db7db5183fc3f609ae687dd529336b74fcba4a6ba75580c9" + }, + { + "id": "trsl:entry:example0010003", + "phase": "trsl:phase:example-001", + "type": "development-credit", + "amount": 2000, + "currency": "USD", + "recognized_at": "2026-10-01T09:00:00Z", + "extension": { + "id": "trsl:extension:service-with-development-allocation", + "version": "1.0" + }, + "evidence_reference": "confidential:evidence:example-001-dc-0003", + "previous_entry_hash": "d9bc08b582a1ffb59adc818b93bf0946619f4a18bde8d4fa96ef07249a2ce58e" + }, + { + "id": "trsl:entry:example0010004", + "phase": "trsl:phase:example-001", + "type": "remission-credit", + "amount": 18000, + "currency": "USD", + "recognized_at": "2026-12-01T00:00:00Z", + "extension": { + "id": "trsl:policy:linear-longstop-v0", + "version": "1.0" + }, + "evidence_reference": "confidential:evidence:example-001-rc-0001", + "previous_entry_hash": "1285da9f580663a8a3fce847ed64fc81c14b29d566243a7f65eb6b4237f5bf4a" + }, + { + "id": "trsl:entry:example0010005", + "phase": "trsl:phase:example-001", + "type": "development-credit", + "amount": 30000, + "currency": "USD", + "recognized_at": "2027-03-01T09:00:00Z", + "extension": { + "id": "trsl:extension:development-license", + "version": "1.0" + }, + "evidence_reference": "confidential:evidence:example-001-dc-0004", + "previous_entry_hash": "244af54599234f8320e3f3f294d1e0890e62bbf1f1c5f4dd27b0d96abc9f330a" + }, + { + "id": "trsl:entry:example0010006", + "phase": "trsl:phase:example-001", + "type": "remission-credit", + "amount": 15000, + "currency": "USD", + "recognized_at": "2027-06-01T00:00:00Z", + "extension": { + "id": "trsl:policy:linear-longstop-v0", + "version": "1.0" + }, + "evidence_reference": "confidential:evidence:example-001-rc-0002", + "previous_entry_hash": "5c360e5510dd860d68198abf80d25d5bf7509b0527cd5acc5f5bea93d31b5e3b" + } +] diff --git a/examples/phase-001/manifest.json b/examples/phase-001/manifest.json new file mode 100644 index 0000000..c312f50 --- /dev/null +++ b/examples/phase-001/manifest.json @@ -0,0 +1,32 @@ +{ + "framework": "TRF-0.1", + "license": "TRSL-0.1", + "phase": { + "id": "trsl:phase:example-001", + "milestone_release": { + "name": "release-1.0", + "source_revision": "abc123", + "artifact_sha256": "44cd1493bd179c1207c7025c1372cdad89aac114e151db27aba2bdf3d55c688a" + }, + "initial_target": { + "amount": 100000, + "currency": "USD" + }, + "target_basis": { + "estimated_effort_days": 1, + "daily_rate": 1000, + "approved_direct_costs": 0, + "target_multiple": 100 + }, + "future_license": "MIT", + "degeneration_policy": "trsl:policy:linear-longstop-v0@1.0", + "longstop_at": "2031-08-01T00:00:00Z", + "ledger": "examples/phase-001/ledger.json" + }, + "extensions": [ + "trsl:extension:development-license@1.0", + "trsl:extension:cost-plus-operations@1.0", + "trsl:extension:phase-sponsorship@1.0", + "trsl:extension:service-with-development-allocation@1.0" + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..31297b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "target-revenue" +version = "0.1.0" +description = "Stage 0 schemas, pure Outstanding Target fold, and offline validators for the Target Revenue Framework (TRF)." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT-0" } +dependencies = [ + "jsonschema>=4.21", + "cryptography>=42.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/target_revenue"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/schemas/conversion_attestation.schema.json b/schemas/conversion_attestation.schema.json new file mode 100644 index 0000000..c69cf60 --- /dev/null +++ b/schemas/conversion_attestation.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://target-revenue.example/schemas/conversion_attestation.schema.json", + "title": "Conversion Attestation", + "description": "Stage 0 schema per specs/TechnicalSpecificationDocument.md §3.5. Evidence of conversion only — never a precondition for conversion status (PRD FR-7/G6, working default Q13). Tooling must be able to recompute conversion from the Phase Manifest and Target Ledger alone, without this document.", + "type": "object", + "required": [ + "phase", + "milestone_release", + "conversion_timestamp", + "future_license", + "final_development_credit", + "final_remission_credit", + "final_outstanding_target", + "ledger_checkpoint" + ], + "additionalProperties": false, + "properties": { + "phase": { + "type": "string", + "pattern": "^trsl:phase:[a-zA-Z0-9._-]+$" + }, + "milestone_release": { + "type": "string", + "description": "Must match phase.milestone_release.name in the Phase Manifest." + }, + "conversion_timestamp": { + "type": "string", + "format": "date-time", + "description": "The moment the ledger fold first reaches Outstanding Target = 0." + }, + "future_license": { + "type": "string", + "enum": ["MIT", "Apache-2.0"] + }, + "final_development_credit": { "type": "number", "minimum": 0 }, + "final_remission_credit": { "type": "number", "minimum": 0 }, + "final_outstanding_target": { + "type": "number", + "const": 0, + "description": "MUST equal 0." + }, + "ledger_checkpoint": { + "type": "string", + "description": "Points to the last ledger entry id or hash included in the fold." + }, + "signature": { + "type": "string", + "description": "Ed25519 signature over the canonical serialization. Optional for Stage 0 fixtures." + } + } +} diff --git a/schemas/extension_contract.schema.json b/schemas/extension_contract.schema.json new file mode 100644 index 0000000..a6e014d --- /dev/null +++ b/schemas/extension_contract.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://target-revenue.example/schemas/extension_contract.schema.json", + "title": "Monetization Extension Contract", + "description": "Stage 0 schema per specs/TechnicalSpecificationDocument.md §3.3. Structural conformance only — the semantic rule that allocation.rule may not redefine core terms is enforced in code (src/target_revenue/validation.py), not expressible in JSON Schema alone.", + "type": "object", + "required": [ + "id", + "version", + "value", + "pricing", + "allocation", + "recognition", + "reversal", + "evidence", + "status" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^trsl:extension:[a-zA-Z0-9._-]+$" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)*$" + }, + "value": { + "type": "object", + "required": ["description"], + "additionalProperties": false, + "properties": { + "description": { "type": "string", "minLength": 1 } + } + }, + "pricing": { + "type": "object", + "required": ["method"], + "additionalProperties": false, + "properties": { + "method": { "type": "string", "minLength": 1 } + } + }, + "allocation": { + "type": "object", + "required": ["rule"], + "additionalProperties": false, + "properties": { + "rule": { "type": "string", "minLength": 1 }, + "default_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Default development_allocation fraction, per canonical profile defaults (working default Q11)." + } + } + }, + "recognition": { + "type": "object", + "required": ["event"], + "additionalProperties": false, + "properties": { + "event": { + "type": "string", + "enum": ["order", "invoice", "payment-settled", "delivery"], + "description": "Working default Q10: payment-settled only for Stage 0 fold inputs." + } + } + }, + "reversal": { + "type": "object", + "required": ["rule"], + "additionalProperties": false, + "properties": { + "rule": { "type": "string", "minLength": 1 } + } + }, + "evidence": { + "type": "object", + "required": ["requirement"], + "additionalProperties": false, + "properties": { + "requirement": { "type": "string", "minLength": 1 } + } + }, + "status": { + "type": "string", + "enum": ["registered", "canonical", "deprecated"], + "description": "Assigned by Trust Service / maintainer review, not the extension author. canonical promotion is a documented human action (SCOPE §4)." + } + } +} diff --git a/schemas/ledger_entry.schema.json b/schemas/ledger_entry.schema.json new file mode 100644 index 0000000..6ddbdcd --- /dev/null +++ b/schemas/ledger_entry.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://target-revenue.example/schemas/ledger_entry.schema.json", + "title": "Target Ledger Entry", + "description": "Stage 0 schema per specs/TechnicalSpecificationDocument.md §3.2 and specs/OpenQuestions-WorkingDefaults.md Q6/Q10/Q14. Append-only; corrections are new compensating entries, never mutation.", + "type": "object", + "required": [ + "id", + "phase", + "type", + "amount", + "currency", + "recognized_at", + "evidence_reference", + "previous_entry_hash" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^trsl:entry:[a-zA-Z0-9]+$", + "description": "Globally unique, monotonically orderable (ULID or equivalent)." + }, + "phase": { + "type": "string", + "pattern": "^trsl:phase:[a-zA-Z0-9._-]+$" + }, + "type": { + "type": "string", + "enum": [ + "development-credit", + "remission-credit", + "credit-reversal", + "remission-correction", + "administrative-correction", + "conversion-checkpoint" + ], + "description": "Closed set per TSD §3.2; not extensible per-project." + }, + "amount": { + "type": "number", + "description": "Positive for credits; the fold interprets sign by entry type (see fold.py). Reversal/correction entries reduce their target's cumulative total." + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "Must match the Phase's initial_target.currency (working default Q6). No FX conversion in the pure fold." + }, + "recognized_at": { + "type": "string", + "format": "date-time", + "description": "Settlement time, not invoice time (Rule 4 / working default Q10 payment-settled)." + }, + "extension": { + "type": "object", + "description": "Required for development-credit/remission-credit entries.", + "required": ["id", "version"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^trsl:(extension|policy):[a-zA-Z0-9._-]+$" + }, + "version": { "type": "string" } + } + }, + "evidence_reference": { + "type": "string", + "description": "URI, may use the confidential: scheme. Tiering per working default Q10 (E0/E1/E2)." + }, + "previous_entry_hash": { + "type": "string", + "pattern": "^[a-f0-9]{64}$|^GENESIS$", + "description": "SHA-256 hex digest of the previous entry's canonical serialization for this Phase, or the literal GENESIS for the first entry." + }, + "signature": { + "type": "string", + "description": "Ed25519 signature over the canonical serialization (working default Q14). Optional in Stage 0 fixtures, required for any public claim." + }, + "reverses": { + "type": "string", + "pattern": "^trsl:entry:[a-zA-Z0-9]+$", + "description": "Required on credit-reversal and remission-correction entries: the entry id being reversed or corrected." + } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "credit-reversal" } } + }, + "then": { "required": ["reverses"] } + }, + { + "if": { + "properties": { "type": { "const": "remission-correction" } } + }, + "then": { "required": ["reverses"] } + }, + { + "if": { + "properties": { + "type": { "enum": ["development-credit", "remission-credit"] } + } + }, + "then": { "required": ["extension"] } + } + ] +} diff --git a/schemas/phase_manifest.schema.json b/schemas/phase_manifest.schema.json new file mode 100644 index 0000000..2023f0f --- /dev/null +++ b/schemas/phase_manifest.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://target-revenue.example/schemas/phase_manifest.schema.json", + "title": "Phase Manifest", + "description": "Stage 0 schema per specs/TechnicalSpecificationDocument.md §3.1 and specs/OpenQuestions-WorkingDefaults.md Q3/Q6/Q8. Field names and semantics are authoritative from spec/TargetRevenueLicenseConcept.md §16.", + "type": "object", + "required": ["framework", "license", "phase"], + "additionalProperties": false, + "properties": { + "framework": { + "type": "string", + "description": "Framework version tag, e.g. TRF-0.1." + }, + "license": { + "type": "string", + "description": "TRSL version tag, e.g. TRSL-0.1." + }, + "phase": { + "type": "object", + "required": [ + "id", + "milestone_release", + "initial_target", + "future_license", + "degeneration_policy", + "longstop_at", + "ledger" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^trsl:phase:[a-zA-Z0-9._-]+$", + "description": "Globally unique, immutable once published." + }, + "milestone_release": { + "type": "object", + "required": ["name", "source_revision"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "source_revision": { "type": "string" }, + "artifact_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Recommended; required if a built artifact (not just source) is governed." + } + } + }, + "initial_target": { + "type": "object", + "required": ["amount", "currency"], + "additionalProperties": false, + "properties": { + "amount": { "type": "number", "exclusiveMinimum": 0 }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 code. Working default Q6: one native currency per Phase." + } + } + }, + "target_basis": { + "type": "object", + "description": "Recommended transparency metadata (working default Q5), not legally required.", + "additionalProperties": false, + "properties": { + "estimated_effort_days": { "type": "number", "minimum": 0 }, + "daily_rate": { "type": "number", "minimum": 0 }, + "approved_direct_costs": { "type": "number", "minimum": 0 }, + "target_multiple": { + "type": "number", + "minimum": 0, + "description": "Open decimal per working default Q4. Guidance classes {0,1,10,100,1000} are not a closed enum." + } + } + }, + "future_license": { + "type": "string", + "enum": ["MIT", "Apache-2.0"], + "description": "Working default Q3: closed enum for Stage 0." + }, + "degeneration_policy": { + "type": "string", + "pattern": "^trsl:policy:[a-zA-Z0-9._-]+@[0-9]+(\\.[0-9]+)*$", + "description": "e.g. trsl:policy:linear-longstop-v0@1.0 (working default Q7)." + }, + "longstop_at": { + "type": "string", + "format": "date-time", + "description": "Required for Stage 0 per working default Q8. Full-remission / maximum-protection instant." + }, + "ledger": { + "type": "string", + "description": "URL or relative path URI to the authoritative Target Ledger for this Phase." + } + } + }, + "extensions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^trsl:extension:[a-zA-Z0-9._-]+@[0-9]+(\\.[0-9]+)*$" + }, + "description": "Optional. Applicable monetization profiles/extensions for this Phase." + } + } +} diff --git a/scripts/generate_golden_phase.py b/scripts/generate_golden_phase.py new file mode 100644 index 0000000..110df51 --- /dev/null +++ b/scripts/generate_golden_phase.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Regenerate examples/phase-001/ledger.json with a correct hash chain. + +The golden Phase package must exercise a real SHA-256 hash chain, not +hand-typed digests. This script builds the entry sequence from +spec/TargetRevenueLicenseConcept.md §23's illustrative example and writes it +out via the same hashing.entry_hash() the library and tests use, so the +fixture and the code that verifies it can never silently drift apart. + +Run: python3 scripts/generate_golden_phase.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) + +from target_revenue import hashing # noqa: E402 + +PHASE_ID = "trsl:phase:example-001" + + +def entry( + entry_id: str, + entry_type: str, + amount: float, + recognized_at: str, + extension_id: str, + extension_version: str, + evidence_reference: str, + previous_entry_hash: str, +) -> dict: + return { + "id": entry_id, + "phase": PHASE_ID, + "type": entry_type, + "amount": amount, + "currency": "USD", + "recognized_at": recognized_at, + "extension": {"id": extension_id, "version": extension_version}, + "evidence_reference": evidence_reference, + "previous_entry_hash": previous_entry_hash, + } + + +def main() -> None: + entries: list[dict] = [] + previous_hash = hashing.GENESIS + + specs = [ + # (id, type, amount, recognized_at, extension_id, evidence_reference) + ( + "trsl:entry:example0010001", + "development-credit", + 25000, + "2026-09-01T09:00:00Z", + "trsl:extension:development-license", + "confidential:evidence:example-001-dc-0001", + ), + ( + "trsl:entry:example0010002", + "development-credit", + 10000, + "2026-09-15T09:00:00Z", + "trsl:extension:phase-sponsorship", + "confidential:evidence:example-001-dc-0002", + ), + ( + "trsl:entry:example0010003", + "development-credit", + 2000, + "2026-10-01T09:00:00Z", + "trsl:extension:service-with-development-allocation", + "confidential:evidence:example-001-dc-0003", + ), + ( + "trsl:entry:example0010004", + "remission-credit", + 18000, + "2026-12-01T00:00:00Z", + "trsl:policy:linear-longstop-v0", + "confidential:evidence:example-001-rc-0001", + ), + ( + "trsl:entry:example0010005", + "development-credit", + 30000, + "2027-03-01T09:00:00Z", + "trsl:extension:development-license", + "confidential:evidence:example-001-dc-0004", + ), + ( + "trsl:entry:example0010006", + "remission-credit", + 15000, + "2027-06-01T00:00:00Z", + "trsl:policy:linear-longstop-v0", + "confidential:evidence:example-001-rc-0002", + ), + ] + + for entry_id, entry_type, amount, recognized_at, ext_id, evidence in specs: + record = entry( + entry_id, + entry_type, + amount, + recognized_at, + ext_id, + "1.0", + evidence, + previous_hash, + ) + entries.append(record) + previous_hash = hashing.entry_hash(record) + + out_path = REPO_ROOT / "examples" / "phase-001" / "ledger.json" + with open(out_path, "w", encoding="utf-8") as f: + json.dump(entries, f, indent=2) + f.write("\n") + print(f"wrote {len(entries)} entries to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/src/target_revenue/__init__.py b/src/target_revenue/__init__.py new file mode 100644 index 0000000..fb1cd6b --- /dev/null +++ b/src/target_revenue/__init__.py @@ -0,0 +1,14 @@ +"""Stage 0 schemas, pure fold, and offline validators for the Target Revenue Framework. + +See specs/TechnicalSpecificationDocument.md for the authoritative data model +and specs/OpenQuestions-WorkingDefaults.md for provisional Stage 0 defaults. +This package implements schema-shaped, pure-function tooling only; it is not +a hosted Trust Service (SCOPE.md §3). +""" + +__all__ = [ + "hashing", + "validation", + "fold", + "conversion", +] diff --git a/src/target_revenue/conversion.py b/src/target_revenue/conversion.py new file mode 100644 index 0000000..bbe8bb6 --- /dev/null +++ b/src/target_revenue/conversion.py @@ -0,0 +1,80 @@ +"""Conversion Event detection and Conversion Attestation generation. + +TSD §3.5 / PRD FR-7 / G6 / working default Q13: the Conversion Event is a +pure fact about the Manifest + Ledger, never a discretionary declaration. +`is_converted` and `conversion_status` below never require an attestation +document to answer "has this Phase converted?" — the attestation, when +generated, is evidence of an already-true fact, not its cause. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from . import fold as fold_module +from . import validation + + +@dataclass(frozen=True) +class ConversionStatus: + """Pure, offline-computable conversion status for a Phase.""" + + is_converted: bool + development_credit: float + remission_credit: float + outstanding_target: float + future_license: str + + +def conversion_status( + manifest: dict[str, Any], entries: list[dict[str, Any]] +) -> ConversionStatus: + """Compute conversion status directly from Manifest + Ledger. + + No attestation document is consulted or required. This function is the + canonical "has this Phase converted" answer for any conformant tool. + """ + result = fold_module.fold_phase(manifest, entries) + return ConversionStatus( + is_converted=result.is_converted, + development_credit=result.development_credit, + remission_credit=result.remission_credit, + outstanding_target=result.outstanding_target, + future_license=manifest["phase"]["future_license"], + ) + + +def generate_attestation( + manifest: dict[str, Any], + entries: list[dict[str, Any]], + conversion_timestamp: str, +) -> dict[str, Any]: + """Build a Conversion Attestation document for an already-converted Phase. + + Raises ValueError if the Phase has not actually converted (Outstanding + Target > 0) — an attestation must never assert a conversion that hasn't + happened, and must never be required to make one happen. + """ + status = conversion_status(manifest, entries) + if not status.is_converted: + raise ValueError( + "cannot generate a Conversion Attestation: Outstanding Target " + f"is {status.outstanding_target}, not 0" + ) + + last_entry = entries[-1] if entries else None + checkpoint = last_entry["id"] if last_entry else "GENESIS" + + attestation = { + "phase": manifest["phase"]["id"], + "milestone_release": manifest["phase"]["milestone_release"]["name"], + "conversion_timestamp": conversion_timestamp, + "future_license": status.future_license, + "final_development_credit": status.development_credit, + "final_remission_credit": status.remission_credit, + "final_outstanding_target": status.outstanding_target, + "ledger_checkpoint": checkpoint, + } + validation.validate_conversion_attestation(attestation) + return attestation diff --git a/src/target_revenue/fold.py b/src/target_revenue/fold.py new file mode 100644 index 0000000..571c604 --- /dev/null +++ b/src/target_revenue/fold.py @@ -0,0 +1,109 @@ +"""Pure Outstanding Target fold over a Phase's Target Ledger. + +Outstanding Target = max(0, Initial Target - Development Credit - Remission +Credit), per spec/TargetRevenueLicenseConcept.md §7.7/§13 and +specs/TechnicalSpecificationDocument.md §3.2. This module contains no I/O: +given the same (initial_target_amount, entries) it always returns the same +result (TSD §6.1 determinism), so any independent implementation can +recompute the same Outstanding Target from the same Manifest + Ledger. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from . import hashing + +#: Entry types that do not carry a numeric effect on the fold. +_INFORMATIONAL_TYPES = frozenset({"conversion-checkpoint"}) + +_KNOWN_TYPES = frozenset( + { + "development-credit", + "remission-credit", + "credit-reversal", + "remission-correction", + "administrative-correction", + "conversion-checkpoint", + } +) + + +@dataclass(frozen=True) +class FoldResult: + """Result of folding a Phase's ledger to a point in the entry sequence.""" + + development_credit: float + remission_credit: float + outstanding_target: float + entries_applied: int + + @property + def is_converted(self) -> bool: + return self.outstanding_target <= 0 + + +def fold_outstanding_target( + initial_target_amount: float, entries: list[dict[str, Any]] +) -> FoldResult: + """Fold an ordered list of ledger entries into cumulative totals. + + Entry-type effects (Stage 0): + development-credit += amount to Development Credit + remission-credit += amount to Remission Credit + credit-reversal -= amount from Development Credit (Rule 8: + compensating entry, never edits the + original) + remission-correction += amount to Remission Credit (amount may be + negative to correct an over-remission) + administrative-correction += amount to Development Credit (Stage 0 + simplification: a generic correction + bucket; not yet specialized by target side) + conversion-checkpoint no numeric effect (marker only) + """ + development_credit = 0.0 + remission_credit = 0.0 + + for entry in entries: + entry_type = entry["type"] + if entry_type not in _KNOWN_TYPES: + raise ValueError(f"unknown ledger entry type: {entry_type!r}") + if entry_type in _INFORMATIONAL_TYPES: + continue + + amount = entry["amount"] + if entry_type == "development-credit": + development_credit += amount + elif entry_type == "remission-credit": + remission_credit += amount + elif entry_type == "credit-reversal": + development_credit -= amount + elif entry_type == "remission-correction": + remission_credit += amount + elif entry_type == "administrative-correction": + development_credit += amount + + outstanding_target = max( + 0.0, initial_target_amount - development_credit - remission_credit + ) + return FoldResult( + development_credit=development_credit, + remission_credit=remission_credit, + outstanding_target=outstanding_target, + entries_applied=len(entries), + ) + + +def fold_phase( + manifest: dict[str, Any], entries: list[dict[str, Any]] +) -> FoldResult: + """Verify a Phase's ledger chain and compute its Outstanding Target. + + Raises ValueError if the hash chain is broken. Does not perform schema + or currency validation — call src.target_revenue.validation for that + before folding untrusted input. + """ + hashing.verify_chain(entries) + initial_target_amount = manifest["phase"]["initial_target"]["amount"] + return fold_outstanding_target(initial_target_amount, entries) diff --git a/src/target_revenue/hashing.py b/src/target_revenue/hashing.py new file mode 100644 index 0000000..9453b8d --- /dev/null +++ b/src/target_revenue/hashing.py @@ -0,0 +1,75 @@ +"""Canonical serialization, SHA-256 hash chaining, and Ed25519 signing. + +Working default Q14 (specs/OpenQuestions-WorkingDefaults.md): hash chain is +SHA-256 over a canonical JSON serialization; signatures are Ed25519 over the +same canonical bytes. This module is the single source of truth for what +"canonical serialization" means so hashing and signing never drift. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) + +GENESIS = "GENESIS" + + +def canonical_bytes(record: dict[str, Any]) -> bytes: + """Deterministic byte representation of a record for hashing/signing. + + Sorted keys, no insignificant whitespace, UTF-8. The `signature` and + `previous_entry_hash` fields are excluded because they are computed from + (or alongside) this representation, not part of what they attest to. + """ + payload = { + k: v + for k, v in record.items() + if k not in ("signature", "previous_entry_hash") + } + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def entry_hash(record: dict[str, Any]) -> str: + """SHA-256 hex digest of a ledger entry's canonical bytes.""" + return hashlib.sha256(canonical_bytes(record)).hexdigest() + + +def verify_chain(entries: list[dict[str, Any]]) -> None: + """Raise ValueError on the first broken link in an entry chain. + + Entries must already be in ledger (append) order. The first entry's + `previous_entry_hash` must equal GENESIS; every subsequent entry's + `previous_entry_hash` must equal the SHA-256 of the prior entry's + canonical bytes. + """ + expected = GENESIS + for i, entry in enumerate(entries): + actual = entry.get("previous_entry_hash") + if actual != expected: + raise ValueError( + f"hash chain broken at entry index {i} (id={entry.get('id')!r}): " + f"expected previous_entry_hash={expected!r}, got {actual!r}" + ) + expected = entry_hash(entry) + + +def sign_record(record: dict[str, Any], private_key: Ed25519PrivateKey) -> str: + """Ed25519 signature (hex) over the record's canonical bytes.""" + return private_key.sign(canonical_bytes(record)).hex() + + +def verify_record_signature( + record: dict[str, Any], signature_hex: str, public_key: Ed25519PublicKey +) -> bool: + """True if signature_hex is a valid Ed25519 signature over record's canonical bytes.""" + try: + public_key.verify(bytes.fromhex(signature_hex), canonical_bytes(record)) + return True + except Exception: + return False diff --git a/src/target_revenue/validation.py b/src/target_revenue/validation.py new file mode 100644 index 0000000..b9cd4c6 --- /dev/null +++ b/src/target_revenue/validation.py @@ -0,0 +1,176 @@ +"""Offline, pure conformance validators for TRF Stage 0 schemas. + +Validates against the .schema.json files under schemas/ (structural +conformance) plus a small set of business rules that JSON Schema cannot +express: cross-record currency consistency (working default Q6), manifest +immutability across versions (Rule 1), and the extension-contract rule that +`allocation.rule` may not redefine core terms (TSD §3.3, PRD FR-4). + +Every function here is pure: given the same input dict(s), it always +produces the same result, with no I/O beyond reading the static schema files +at import/call time (TSD §6.1 determinism). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import jsonschema + +_SCHEMAS_DIR = Path(__file__).resolve().parents[2] / "schemas" + +# TSD §6.2/§3.3: an extension's allocation.rule must not redefine these. +CORE_TERMS = [ + "Phase", + "Milestone Release", + "Initial Target", + "Development Credit", + "Remission Credit", + "Outstanding Target", + "Conversion Event", + "Future License", +] + + +class ConformanceError(ValueError): + """Raised with all collected validation errors for a single record.""" + + def __init__(self, errors: list[str]): + self.errors = errors + super().__init__("; ".join(errors)) + + +def _load_schema(filename: str) -> dict[str, Any]: + with open(_SCHEMAS_DIR / filename, encoding="utf-8") as f: + return json.load(f) + + +def _schema_errors(schema_filename: str, record: dict[str, Any]) -> list[str]: + schema = _load_schema(schema_filename) + validator = jsonschema.Draft202012Validator(schema) + return [ + f"{'/'.join(str(p) for p in e.path) or ''}: {e.message}" + for e in sorted(validator.iter_errors(record), key=lambda e: list(e.path)) + ] + + +# --- Phase Manifest -------------------------------------------------------- + + +def validate_phase_manifest(manifest: dict[str, Any]) -> None: + """Raise ConformanceError if the manifest is not schema-conformant.""" + errors = _schema_errors("phase_manifest.schema.json", manifest) + if errors: + raise ConformanceError(errors) + + +def check_manifest_immutability( + published: dict[str, Any], candidate: dict[str, Any] +) -> list[str]: + """Compare a published manifest against a proposed replacement. + + Returns a list of violations (empty if none). Per Rule 1: phase.id must + not change, and phase.initial_target.amount must not change except + through an explicit, versioned correction record distinct from this + check (Stage 0 has no such record type yet, so any change here is + flagged). + """ + errors: list[str] = [] + old_phase = published.get("phase", {}) + new_phase = candidate.get("phase", {}) + if old_phase.get("id") != new_phase.get("id"): + errors.append( + f"phase.id changed from {old_phase.get('id')!r} to {new_phase.get('id')!r}" + ) + old_target = old_phase.get("initial_target", {}) + new_target = new_phase.get("initial_target", {}) + if old_target.get("amount") != new_target.get("amount"): + errors.append( + "phase.initial_target.amount changed from " + f"{old_target.get('amount')!r} to {new_target.get('amount')!r} " + "without an explicit, versioned correction record" + ) + if old_target.get("currency") != new_target.get("currency"): + errors.append( + "phase.initial_target.currency changed from " + f"{old_target.get('currency')!r} to {new_target.get('currency')!r}" + ) + return errors + + +# --- Target Ledger Entry ---------------------------------------------------- + + +def validate_ledger_entry(entry: dict[str, Any]) -> None: + """Raise ConformanceError if the entry is not schema-conformant.""" + errors = _schema_errors("ledger_entry.schema.json", entry) + if errors: + raise ConformanceError(errors) + + +def check_currency_consistency( + manifest: dict[str, Any], entries: list[dict[str, Any]] +) -> list[str]: + """Working default Q6: every entry's currency must match the Phase's.""" + native = manifest.get("phase", {}).get("initial_target", {}).get("currency") + errors = [] + for entry in entries: + if entry.get("currency") != native: + errors.append( + f"entry {entry.get('id')!r} currency {entry.get('currency')!r} " + f"does not match Phase native currency {native!r}" + ) + return errors + + +# --- Monetization Extension Contract --------------------------------------- + + +def check_extension_core_term_redefinition(extension: dict[str, Any]) -> list[str]: + """Heuristic check that allocation.rule does not redefine a core term. + + Stage 0 limitation: this is a pattern-based heuristic (assignment-like + phrasing immediately following a core term name), not a full semantic + check. It is deterministic and sufficient to reject the deliberately + non-conforming fixture required by WP-0002-T04; it is not a substitute + for human review of new extensions before promotion to `canonical`. + """ + rule_text = extension.get("allocation", {}).get("rule", "") + normalized = rule_text.lower() + errors: list[str] = [] + forbidden_patterns = ("=", "redefine", "means", "is defined as", "shall be") + for term in CORE_TERMS: + term_l = term.lower() + if term_l not in normalized: + continue + for pattern in forbidden_patterns: + if f"{term_l} {pattern}" in normalized or f"{term_l}{pattern}" in normalized: + errors.append( + f"allocation.rule appears to redefine core term {term!r} " + f"(matched {term_l + ' ' + pattern!r})" + ) + return errors + + +def validate_extension_contract(extension: dict[str, Any]) -> None: + """Raise ConformanceError on structural or core-term-redefinition failure. + + A non-conforming extension is rejected here, not silently accepted with + reduced trust (TSD §3.3 conformance rule). + """ + errors = _schema_errors("extension_contract.schema.json", extension) + errors += check_extension_core_term_redefinition(extension) + if errors: + raise ConformanceError(errors) + + +# --- Conversion Attestation -------------------------------------------------- + + +def validate_conversion_attestation(attestation: dict[str, Any]) -> None: + """Raise ConformanceError if the attestation is not schema-conformant.""" + errors = _schema_errors("conversion_attestation.schema.json", attestation) + if errors: + raise ConformanceError(errors) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..312e859 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +EXAMPLES_DIR = REPO_ROOT / "examples" / "phase-001" +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" + + +def load_json(path: Path) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def golden_manifest() -> dict: + return load_json(EXAMPLES_DIR / "manifest.json") + + +def golden_entries() -> list[dict]: + return load_json(EXAMPLES_DIR / "ledger.json") + + +def golden_extension(name: str) -> dict: + return load_json(EXAMPLES_DIR / "extensions" / f"{name}.json") diff --git a/tests/fixtures/non_conforming_extension.json b/tests/fixtures/non_conforming_extension.json new file mode 100644 index 0000000..fa67285 --- /dev/null +++ b/tests/fixtures/non_conforming_extension.json @@ -0,0 +1,24 @@ +{ + "id": "trsl:extension:bad-redefinition", + "version": "1.0", + "value": { + "description": "Deliberately non-conforming fixture for WP-0002-T04: attempts to redefine a core term instead of only classifying a payment." + }, + "pricing": { + "method": "flat-fee" + }, + "allocation": { + "rule": "Outstanding Target = 0 for any payment received under this extension, regardless of the Phase ledger.", + "default_rate": 1.0 + }, + "recognition": { + "event": "payment-settled" + }, + "reversal": { + "rule": "No reversals." + }, + "evidence": { + "requirement": "None." + }, + "status": "registered" +} diff --git a/tests/test_conversion.py b/tests/test_conversion.py new file mode 100644 index 0000000..b18c13a --- /dev/null +++ b/tests/test_conversion.py @@ -0,0 +1,52 @@ +import pytest +from conftest import golden_entries, golden_manifest + +from target_revenue import conversion, validation + + +def test_conversion_status_not_converted_partway_through(): + manifest = golden_manifest() + status = conversion.conversion_status(manifest, golden_entries()[:4]) + assert status.is_converted is False + assert status.outstanding_target == 45000 + + +def test_conversion_status_converted_after_full_sequence(): + manifest = golden_manifest() + status = conversion.conversion_status(manifest, golden_entries()) + assert status.is_converted is True + assert status.outstanding_target == 0 + assert status.future_license == "MIT" + + +def test_conversion_status_requires_no_attestation_document(): + """PRD FR-7/G6: conversion status must be computable from Manifest + + Ledger alone, with no attestation file consulted or required.""" + manifest = golden_manifest() + entries = golden_entries() + status_without_attestation = conversion.conversion_status(manifest, entries) + assert status_without_attestation.is_converted is True + + +def test_generate_attestation_for_converted_phase(): + manifest = golden_manifest() + entries = golden_entries() + attestation = conversion.generate_attestation( + manifest, entries, conversion_timestamp="2027-06-01T00:00:00Z" + ) + validation.validate_conversion_attestation(attestation) + assert attestation["final_outstanding_target"] == 0 + assert attestation["final_development_credit"] == 67000 + assert attestation["final_remission_credit"] == 33000 + assert attestation["future_license"] == "MIT" + assert attestation["phase"] == "trsl:phase:example-001" + assert attestation["ledger_checkpoint"] == entries[-1]["id"] + + +def test_generate_attestation_rejects_unconverted_phase(): + manifest = golden_manifest() + partial_entries = golden_entries()[:4] # Outstanding Target still 45000 + with pytest.raises(ValueError, match="Outstanding Target"): + conversion.generate_attestation( + manifest, partial_entries, conversion_timestamp="2026-12-01T00:00:00Z" + ) diff --git a/tests/test_extension_conformance.py b/tests/test_extension_conformance.py new file mode 100644 index 0000000..e886dbf --- /dev/null +++ b/tests/test_extension_conformance.py @@ -0,0 +1,55 @@ +import copy + +import pytest +from conftest import golden_extension, load_json, FIXTURES_DIR + +from target_revenue import validation + + +@pytest.mark.parametrize( + "name", + [ + "development-license", + "cost-plus-operations", + "phase-sponsorship", + "service-with-development-allocation", + ], +) +def test_canonical_stage0_extensions_are_conformant(name): + validation.validate_extension_contract(golden_extension(name)) + + +def test_non_conforming_extension_is_rejected(): + extension = load_json(FIXTURES_DIR / "non_conforming_extension.json") + with pytest.raises(validation.ConformanceError) as excinfo: + validation.validate_extension_contract(extension) + assert "Outstanding Target" in str(excinfo.value) + + +def test_extension_missing_required_field_is_rejected(): + extension = copy.deepcopy(golden_extension("development-license")) + del extension["evidence"] + with pytest.raises(validation.ConformanceError): + validation.validate_extension_contract(extension) + + +def test_extension_bad_status_is_rejected(): + extension = copy.deepcopy(golden_extension("development-license")) + extension["status"] = "endorsed" # not in {registered, canonical, deprecated} + with pytest.raises(validation.ConformanceError): + validation.validate_extension_contract(extension) + + +def test_core_term_redefinition_heuristic_is_case_insensitive(): + extension = copy.deepcopy(golden_extension("development-license")) + extension["allocation"]["rule"] = "Development Credit means whatever the licensor prefers." + errors = validation.check_extension_core_term_redefinition(extension) + assert errors + assert "Development Credit" in errors[0] + + +def test_legitimate_rule_referencing_core_term_without_redefining_is_allowed(): + extension = golden_extension("development-license") + # References "Development Credit" as a target, not a redefinition. + errors = validation.check_extension_core_term_redefinition(extension) + assert errors == [] diff --git a/tests/test_ledger_fold.py b/tests/test_ledger_fold.py new file mode 100644 index 0000000..98cdf45 --- /dev/null +++ b/tests/test_ledger_fold.py @@ -0,0 +1,133 @@ +import copy + +import pytest +from conftest import golden_entries, golden_manifest + +from target_revenue import fold, hashing, validation + + +def test_golden_entries_are_individually_conformant(): + for entry in golden_entries(): + validation.validate_ledger_entry(entry) + + +def test_golden_entries_currency_matches_phase(): + errors = validation.check_currency_consistency(golden_manifest(), golden_entries()) + assert errors == [] + + +def test_currency_mismatch_is_detected(): + manifest = golden_manifest() + entries = copy.deepcopy(golden_entries()) + entries[0]["currency"] = "EUR" + errors = validation.check_currency_consistency(manifest, entries) + assert len(errors) == 1 + assert "EUR" in errors[0] + + +def test_golden_hash_chain_is_valid(): + hashing.verify_chain(golden_entries()) + + +def test_tampered_entry_breaks_hash_chain(): + entries = copy.deepcopy(golden_entries()) + entries[2]["amount"] = 999999 # tamper after the chain was computed + with pytest.raises(ValueError, match="hash chain broken"): + hashing.verify_chain(entries) + + +def test_reordered_entries_break_hash_chain(): + entries = copy.deepcopy(golden_entries()) + entries[1], entries[2] = entries[2], entries[1] + with pytest.raises(ValueError, match="hash chain broken"): + hashing.verify_chain(entries) + + +def test_fold_matches_concept_section_23_first_checkpoint(): + """spec/TargetRevenueLicenseConcept.md §23: after the first four entries, + Development Credit $37,000, Remission Credit $18,000, Outstanding $45,000.""" + manifest = golden_manifest() + first_four = golden_entries()[:4] + result = fold.fold_outstanding_target( + manifest["phase"]["initial_target"]["amount"], first_four + ) + assert result.development_credit == 37000 + assert result.remission_credit == 18000 + assert result.outstanding_target == 45000 + assert not result.is_converted + + +def test_fold_reaches_zero_and_converts(): + """spec/TargetRevenueLicenseConcept.md §23: full six-entry sequence + reaches Outstanding Target = 0 (Development Credit $67,000 + Remission + Credit $33,000 = Initial Target $100,000).""" + manifest = golden_manifest() + result = fold.fold_phase(manifest, golden_entries()) + assert result.development_credit == 67000 + assert result.remission_credit == 33000 + assert result.outstanding_target == 0 + assert result.is_converted + + +def test_credit_reversal_reduces_development_credit(): + entries = [ + { + "id": "trsl:entry:t0001", + "phase": "trsl:phase:t", + "type": "development-credit", + "amount": 5000, + "currency": "USD", + "recognized_at": "2026-01-01T00:00:00Z", + "extension": {"id": "trsl:extension:development-license", "version": "1.0"}, + "evidence_reference": "confidential:evidence:t0001", + "previous_entry_hash": "GENESIS", + }, + ] + entries.append( + { + "id": "trsl:entry:t0002", + "phase": "trsl:phase:t", + "type": "credit-reversal", + "amount": 5000, + "currency": "USD", + "recognized_at": "2026-01-02T00:00:00Z", + "evidence_reference": "confidential:evidence:t0002-refund", + "previous_entry_hash": hashing.entry_hash(entries[0]), + "reverses": "trsl:entry:t0001", + } + ) + result = fold.fold_outstanding_target(100000, entries) + assert result.development_credit == 0 + assert result.outstanding_target == 100000 + + +def test_remission_correction_can_reduce_over_remission(): + entries = [ + { + "id": "trsl:entry:t0001", + "phase": "trsl:phase:t", + "type": "remission-credit", + "amount": 20000, + "currency": "USD", + "recognized_at": "2026-01-01T00:00:00Z", + "extension": {"id": "trsl:policy:linear-longstop-v0", "version": "1.0"}, + "evidence_reference": "confidential:evidence:t0001", + "previous_entry_hash": "GENESIS", + }, + ] + entries.append( + { + "id": "trsl:entry:t0002", + "phase": "trsl:phase:t", + "type": "remission-correction", + "amount": -5000, + "currency": "USD", + "recognized_at": "2026-01-02T00:00:00Z", + "evidence_reference": "confidential:evidence:t0002-correction", + "previous_entry_hash": hashing.entry_hash(entries[0]), + "reverses": "trsl:entry:t0001", + } + ) + result = fold.fold_outstanding_target(100000, entries) + assert result.remission_credit == 15000 + assert result.outstanding_target == 85000 diff --git a/tests/test_manifest_validation.py b/tests/test_manifest_validation.py new file mode 100644 index 0000000..434bc4b --- /dev/null +++ b/tests/test_manifest_validation.py @@ -0,0 +1,61 @@ +import copy + +import pytest +from conftest import golden_manifest + +from target_revenue import validation + + +def test_golden_manifest_is_conformant(): + validation.validate_phase_manifest(golden_manifest()) + + +def test_manifest_missing_longstop_is_rejected(): + manifest = copy.deepcopy(golden_manifest()) + del manifest["phase"]["longstop_at"] + with pytest.raises(validation.ConformanceError): + validation.validate_phase_manifest(manifest) + + +def test_manifest_bad_future_license_is_rejected(): + manifest = copy.deepcopy(golden_manifest()) + manifest["phase"]["future_license"] = "GPL-3.0" + with pytest.raises(validation.ConformanceError): + validation.validate_phase_manifest(manifest) + + +def test_manifest_missing_required_field_is_rejected(): + manifest = copy.deepcopy(golden_manifest()) + del manifest["phase"]["initial_target"] + with pytest.raises(validation.ConformanceError): + validation.validate_phase_manifest(manifest) + + +def test_manifest_rejects_unknown_top_level_field(): + manifest = copy.deepcopy(golden_manifest()) + manifest["unexpected_field"] = True + with pytest.raises(validation.ConformanceError): + validation.validate_phase_manifest(manifest) + + +def test_initial_target_amount_immutable_across_versions(): + published = golden_manifest() + candidate = copy.deepcopy(published) + candidate["phase"]["initial_target"]["amount"] = 70000 + errors = validation.check_manifest_immutability(published, candidate) + assert errors, "reducing initial_target.amount must be flagged" + assert "initial_target.amount" in errors[0] + + +def test_phase_id_immutable_across_versions(): + published = golden_manifest() + candidate = copy.deepcopy(published) + candidate["phase"]["id"] = "trsl:phase:example-002" + errors = validation.check_manifest_immutability(published, candidate) + assert errors + assert "phase.id" in errors[0] + + +def test_unchanged_manifest_has_no_immutability_violations(): + manifest = golden_manifest() + assert validation.check_manifest_immutability(manifest, manifest) == [] diff --git a/workplans/TREV-WP-0002-trust-service-foundation.md b/workplans/TREV-WP-0002-trust-service-foundation.md index 689521f..1603513 100644 --- a/workplans/TREV-WP-0002-trust-service-foundation.md +++ b/workplans/TREV-WP-0002-trust-service-foundation.md @@ -51,6 +51,14 @@ state_hub_task_id: "bdaa3e6a-e13d-4d4f-a6b0-9c56c18850a9" human_accept_required: true ``` +Result 2026-07-28: Drafted `docs/adr/ADR-0001-stage0-library-stack.md` +(status: **proposed**) — Python 3.11+, `jsonschema`, `pytest`, `hatchling` +src-layout; SHA-256 canonical-serialization hash chain; Ed25519 signing. +T02–T06 were implemented against this proposal so work could proceed in +parallel, per the workplan note that "agents may draft the ADR for review." +**This task stays `todo`** until a maintainer accepts or revises the ADR — +implementation proceeding does not constitute acceptance. + `specs/TechnicalSpecificationDocument.md` is non-binding on language and storage (§10). Record an ADR for the **Stage 0 library only**: language for JSON Schema (or equivalent) validators and the pure fold, canonical @@ -68,11 +76,18 @@ technology choices. Agents may draft the ADR for review. ```task id: TREV-WP-0002-T02 -status: todo +status: done priority: high state_hub_task_id: "92568684-2dff-497c-9593-7a0e91cb95a3" ``` +Result 2026-07-28: `schemas/phase_manifest.schema.json` + +`src/target_revenue/validation.py::validate_phase_manifest` / +`check_manifest_immutability`. `longstop_at` required, `future_license` +closed to `{MIT, Apache-2.0}`, `phase.id`/`initial_target` immutability +checked across manifest versions. Covered by +`tests/test_manifest_validation.py` (8 tests, all passing). + Implement machine-readable schema + pure validator for Phase Manifest (TSD §3.1), applying Stage 0 working defaults: @@ -89,11 +104,19 @@ this workplan. ```task id: TREV-WP-0002-T03 -status: todo +status: done priority: high state_hub_task_id: "df4133ee-9776-438d-901a-78a906356cfb" ``` +Result 2026-07-28: `schemas/ledger_entry.schema.json` (six-type closed enum, +`reverses` required on `credit-reversal`/`remission-correction`); +`src/target_revenue/hashing.py` (SHA-256 canonical-serialization chain + +Ed25519 sign/verify helpers); `src/target_revenue/fold.py` +(`fold_outstanding_target` / `fold_phase`, pure `max(0, T0-C-R)`). Covered by +`tests/test_ledger_fold.py` (10 tests: currency mismatch, tamper/reorder +detection, §23 checkpoint numbers, reversal/correction paths). + Implement ledger entry schema (TSD §3.2): six entry types, currency match to Phase native currency (Q6), `previous_entry_hash` chain, signature field shape. Implement Outstanding Target as a pure fold: @@ -108,11 +131,19 @@ hosted append service is out of scope. ```task id: TREV-WP-0002-T04 -status: todo +status: done priority: medium state_hub_task_id: "8454269e-fb3d-4751-b564-ef0cc33f9c97" ``` +Result 2026-07-28: `schemas/extension_contract.schema.json` + +`validation.validate_extension_contract` / +`check_extension_core_term_redefinition` (documented as a Stage 0 pattern +heuristic, not full semantic review). Four working-default-Q11 fixtures +under `examples/phase-001/extensions/` (all `registered`); one deliberately +non-conforming fixture at `tests/fixtures/non_conforming_extension.json`. +Covered by `tests/test_extension_conformance.py` (9 tests). + Implement Monetization Extension Contract schema (TSD §3.3) and conformance check: required fields; `allocation.rule` must not redefine core terms. Support status values `registered` / `canonical` / `deprecated` as data; @@ -125,11 +156,18 @@ practical). ```task id: TREV-WP-0002-T05 -status: todo +status: done priority: high state_hub_task_id: "e60c8ccf-cd98-4b15-8ad2-e7718b08acc4" ``` +Result 2026-07-28: `schemas/conversion_attestation.schema.json` + +`src/target_revenue/conversion.py` (`conversion_status`, +`generate_attestation`). `conversion_status` never reads an attestation +file; `generate_attestation` raises `ValueError` if called before Outstanding +Target actually reaches zero. Covered by `tests/test_conversion.py` (5 +tests) plus the generated `examples/phase-001/attestation.json`. + Implement Conversion Event detection as pure read over Manifest + fold (Outstanding Target reaches zero) and Conversion Attestation **document schema** / optional generator (TSD §3.5). Enforce: attestation is never a @@ -140,11 +178,19 @@ an attestation file (PRD FR-7 / G6; working default Q13). ```task id: TREV-WP-0002-T06 -status: todo +status: done priority: medium state_hub_task_id: "90711ebf-3d89-4b45-8300-489f39cfa3cc" ``` +Result 2026-07-28: `examples/phase-001/` (manifest, six-entry ledger, four +extension fixtures, generated attestation) built via +`scripts/generate_golden_phase.py` so the hash chain is computed by the +library itself, never hand-typed. Full pytest suite: 32 tests across +`tests/test_manifest_validation.py`, `tests/test_ledger_fold.py`, +`tests/test_extension_conformance.py`, `tests/test_conversion.py` — all +passing, no network dependency (`python3 -m pytest tests/`). + Build `examples/phase-001/` (or equivalent) exercising concept §23: - Initial Target $100,000;