368 lines
13 KiB
Python
368 lines
13 KiB
Python
|
|
"""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
|