target-revenue/src/target_revenue/remission.py
tegwick 3064c0fe0c Go-live T05 + WP-0013/0014: first Phase and Control Plane completion
Accept WP-0008-T05 for trsl:phase:info-tech-canon-service-surface
(history/260805-T05-GoLive-info-tech-canon.md). Finish WP-0013 remission
automation and WP-0014 extension/breach/attestation Control Plane UI.
Update SCOPE, README, and pilot-candidate notes for pilot Stage 1.
2026-08-05 16:00:06 +02:00

367 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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