"""Remission Credit calculation and ledger application (WP-0013). Implements `trsl:policy:linear-longstop-v0@1.0` from `specs/policies/linear-longstop-v0.md`: R(t) = T0 × clamp((t − t0)/(tL − t0), 0, 1) Design decisions (WP-0013-T01, recorded 2026-08-05): 1. **t0 (Phase activation)** is the Trust Service registration timestamp (`phase_manifests.registered_at`). No new manifest field: in Stage 0 a Phase is not active until registered. Pure callers pass `t0` explicitly so offline packages stay free of hosting state. 2. **Cadence** is monthly UTC by convention for scheduled runs (1st of each month 00:00 UTC, or longstop if sooner). The pure model itself is *cumulative*, not period-keyed: each invocation remits `max(0, R(as_of) − already_recorded_policy_remission)`. Re-running at the same `as_of` is a no-op; catching up after a missed schedule works without double-counting. On-demand apply uses the same delta formula. 3. **Idempotency** follows from (2). Policy-produced entries are identified by `type == remission-credit` and `extension.id` matching the policy (corrections stay out of the "already remitted" sum so a deliberate `remission-correction` is not silently undone by the next run). 4. **Actor** is a dedicated Licensor credential labeled `system:policy-engine` (rights: operator). Policy-driven entries are not attributed to a human; `submitted_by_token` is never left null. The credential is auto-issued on first use per Licensor tenant. """ from __future__ import annotations import secrets from dataclasses import dataclass from datetime import datetime, timezone from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from psycopg import Connection from .registry import Licensor #: Credential label for the non-human actor that submits policy remissions. POLICY_ENGINE_CREDENTIAL_LABEL = "system:policy-engine" #: Dust floor: skip ledger writes smaller than this (currency major units). MIN_REMISSION_AMOUNT = 0.01 LINEAR_LONGSTOP_V0_POLICY_ID = "trsl:policy:linear-longstop-v0" LINEAR_LONGSTOP_V0_VERSION = "1.0" LINEAR_LONGSTOP_V0_URN = f"{LINEAR_LONGSTOP_V0_POLICY_ID}@{LINEAR_LONGSTOP_V0_VERSION}" @dataclass(frozen=True) class RemissionPlan: """Result of planning one policy-driven remission at a point in time.""" policy_id: str policy_version: str cumulative_expected: float already_remitted: float delta: float as_of: datetime t0: datetime longstop_at: datetime initial_target: float @property def should_append(self) -> bool: return self.delta >= MIN_REMISSION_AMOUNT def parse_policy_urn(urn: str) -> tuple[str, str]: """Split `trsl:policy:slug@version` into (id, version).""" if "@" not in urn: raise ValueError(f"policy URN missing @version: {urn!r}") policy_id, version = urn.rsplit("@", 1) if not policy_id.startswith("trsl:policy:"): raise ValueError(f"not a degeneration policy URN: {urn!r}") return policy_id, version def _parse_ts(ts: str | datetime) -> datetime: if isinstance(ts, datetime): if ts.tzinfo is None: raise ValueError("timestamps must be timezone-aware") return ts dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) if dt.tzinfo is None: raise ValueError("timestamps must be timezone-aware") return dt def clamp01(x: float) -> float: return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x def cumulative_remission( initial_target: float, t0: datetime, longstop_at: datetime, as_of: datetime, ) -> float: """R(t) for linear-longstop-v0. Pure; no I/O. Before t0 → 0; at/after longstop → full Initial Target; linear in between. """ t0 = _parse_ts(t0) longstop_at = _parse_ts(longstop_at) as_of = _parse_ts(as_of) if initial_target <= 0: raise ValueError("initial_target must be positive") span = (longstop_at - t0).total_seconds() if span <= 0: # Degenerate longstop at or before activation: full remission once as_of >= t0. return float(initial_target) if as_of >= t0 else 0.0 progress = clamp01((as_of - t0).total_seconds() / span) return float(initial_target) * progress def policy_remission_already_recorded( entries: list[dict[str, Any]], policy_id: str = LINEAR_LONGSTOP_V0_POLICY_ID, ) -> float: """Sum of `remission-credit` amounts produced by this policy id. Does not include `remission-correction` or `administrative-correction-remission` — those are deliberate human adjustments that must not be auto-undone by the next policy run. """ total = 0.0 for entry in entries: if entry.get("type") != "remission-credit": continue ext = entry.get("extension") or {} if ext.get("id") == policy_id: total += float(entry["amount"]) return total def plan_remission( manifest: dict[str, Any], entries: list[dict[str, Any]], t0: datetime, as_of: datetime, ) -> RemissionPlan | None: """Plan the next policy-driven remission for a Phase, or None if N/A. Returns None when the Phase's degeneration_policy is not a supported automated policy (today: only linear-longstop-v0). Raises ValueError on missing longstop / bad timestamps. """ policy_urn = manifest["phase"]["degeneration_policy"] try: policy_id, policy_version = parse_policy_urn(policy_urn) except ValueError: return None if policy_id != LINEAR_LONGSTOP_V0_POLICY_ID: return None initial = float(manifest["phase"]["initial_target"]["amount"]) longstop_at = _parse_ts(manifest["phase"]["longstop_at"]) t0_dt = _parse_ts(t0) as_of_dt = _parse_ts(as_of) expected = cumulative_remission(initial, t0_dt, longstop_at, as_of_dt) already = policy_remission_already_recorded(entries, policy_id) # Never remit more than still outstanding against Initial Target when # other credits already reduced it — fold clamps Outstanding at 0, but # over-remitting relative to T0 would still inflate Remission Credit # facts. Cap cumulative expected at T0 (formula already does) and delta # at max(0, T0 - already) is implicit. Also do not reverse over-remission # via negative delta (corrections handle that). delta = max(0.0, expected - already) # Round to cents to avoid float dust ledger spam. delta = round(delta, 2) expected = round(expected, 2) return RemissionPlan( policy_id=policy_id, policy_version=policy_version, cumulative_expected=expected, already_remitted=round(already, 2), delta=delta, as_of=as_of_dt, t0=t0_dt, longstop_at=longstop_at, initial_target=initial, ) def build_remission_entry_input( phase_id: str, plan: RemissionPlan, currency: str, entry_id: str | None = None, ) -> dict[str, Any] | None: """Build a ledger entry input (no previous_entry_hash/signature). Returns None when `plan.should_append` is false (idempotent no-op). """ if not plan.should_append: return None if entry_id is None: entry_id = f"trsl:entry:rem{secrets.token_hex(12)}" as_of_iso = plan.as_of.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") return { "id": entry_id, "phase": phase_id, "type": "remission-credit", "amount": plan.delta, "currency": currency, "recognized_at": as_of_iso, "extension": { "id": plan.policy_id, "version": plan.policy_version, }, "evidence_reference": ( f"{plan.policy_id}@{plan.policy_version}" f"#as_of={as_of_iso}" f"#cumulative={plan.cumulative_expected}" ), } def next_monthly_boundary(as_of: datetime) -> datetime: """Next 1st-of-month 00:00 UTC strictly after `as_of`.""" as_of = _parse_ts(as_of).astimezone(timezone.utc) if as_of.month == 12: candidate = datetime(as_of.year + 1, 1, 1, tzinfo=timezone.utc) else: candidate = datetime(as_of.year, as_of.month + 1, 1, tzinfo=timezone.utc) # If as_of is exactly on a boundary, still move to the *next* one # (scheduled run at T has already been eligible for that instant). if as_of >= candidate: if candidate.month == 12: candidate = datetime(candidate.year + 1, 1, 1, tzinfo=timezone.utc) else: candidate = datetime(candidate.year, candidate.month + 1, 1, tzinfo=timezone.utc) return candidate def next_scheduled_remission_at(as_of: datetime, longstop_at: datetime) -> datetime | None: """Next scheduled recognition instant: min(next monthly UTC, longstop), if still future.""" as_of = _parse_ts(as_of) longstop_at = _parse_ts(longstop_at) if as_of >= longstop_at: return None monthly = next_monthly_boundary(as_of) return monthly if monthly < longstop_at else longstop_at # --- Hosted application path ------------------------------------------------ def get_phase_registered_at(conn: "Connection", phase_id: str) -> datetime | None: row = conn.execute( "SELECT registered_at FROM phase_manifests WHERE phase_id = %s", (phase_id,), ).fetchone() return row[0] if row else None def ensure_policy_engine_credential(conn: "Connection", licensor_id: str) -> "Licensor": """Return the `system:policy-engine` credential for a Licensor, creating it if needed.""" from . import registry from .registry import Licensor row = conn.execute( """ SELECT token, licensor_id, credential_label, rights FROM licensors WHERE licensor_id = %s AND credential_label = %s AND revoked_at IS NULL LIMIT 1 """, (licensor_id, POLICY_ENGINE_CREDENTIAL_LABEL), ).fetchone() if row is not None: token, lid, label, rights = row return Licensor(token=token, licensor_id=lid, credential_label=label, rights=rights) return registry.issue_sub_credential( conn, licensor_id=licensor_id, credential_label=POLICY_ENGINE_CREDENTIAL_LABEL, rights="operator", issued_by="system:remission-automation", ) def apply_remission_for_phase( conn: "Connection", phase_id: str, signing_key: "Ed25519PrivateKey", as_of: datetime | None = None, ) -> dict[str, Any] | None: """Compute and append a policy remission entry for one Phase if needed. Returns the signed entry if one was written, else None (already current, unsupported policy, or phase missing). Uses the Phase owner's `system:policy-engine` credential for `submitted_by_token`. """ from . import ledger, registry from .registry import RegistrationError if as_of is None: as_of = datetime.now(timezone.utc) else: as_of = _parse_ts(as_of) manifest = registry.get_phase_manifest(conn, phase_id) if manifest is None: raise RegistrationError(f"phase {phase_id!r} is not registered") registered_at = get_phase_registered_at(conn, phase_id) if registered_at is None: raise RegistrationError(f"phase {phase_id!r} has no registered_at") if registered_at.tzinfo is None: registered_at = registered_at.replace(tzinfo=timezone.utc) entries = ledger.get_ledger(conn, phase_id) plan = plan_remission(manifest, entries, t0=registered_at, as_of=as_of) if plan is None: raise RegistrationError( f"phase {phase_id!r} degeneration_policy is not an automated policy " f"(supported: {LINEAR_LONGSTOP_V0_URN})" ) entry_input = build_remission_entry_input( phase_id, plan, currency=manifest["phase"]["initial_target"]["currency"], ) if entry_input is None: return None licensor_id = conn.execute( "SELECT licensor_id FROM phase_manifests WHERE phase_id = %s", (phase_id,), ).fetchone()[0] engine = ensure_policy_engine_credential(conn, licensor_id) return ledger.append_entry(conn, engine, phase_id, entry_input, signing_key) def apply_remission_for_all_phases( conn: "Connection", signing_key: "Ed25519PrivateKey", as_of: datetime | None = None, ) -> list[dict[str, Any]]: """Run policy remission for every registered Phase. Returns appended entries.""" from .registry import RegistrationError rows = conn.execute("SELECT phase_id FROM phase_manifests ORDER BY phase_id").fetchall() written: list[dict[str, Any]] = [] for (phase_id,) in rows: try: entry = apply_remission_for_phase(conn, phase_id, signing_key, as_of=as_of) except RegistrationError: # Skip Phases with unsupported policies rather than aborting the batch. continue if entry is not None: written.append(entry) return written