Implement TREV-WP-0002 Stage 0 foundation: schemas, pure fold, golden Phase
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 <noreply@anthropic.com>
This commit is contained in:
parent
658b1af00d
commit
57c7111cbc
28 changed files with 1751 additions and 7 deletions
14
src/target_revenue/__init__.py
Normal file
14
src/target_revenue/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
80
src/target_revenue/conversion.py
Normal file
80
src/target_revenue/conversion.py
Normal file
|
|
@ -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
|
||||
109
src/target_revenue/fold.py
Normal file
109
src/target_revenue/fold.py
Normal file
|
|
@ -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)
|
||||
75
src/target_revenue/hashing.py
Normal file
75
src/target_revenue/hashing.py
Normal file
|
|
@ -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
|
||||
176
src/target_revenue/validation.py
Normal file
176
src/target_revenue/validation.py
Normal file
|
|
@ -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 '<root>'}: {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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue