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.
367 lines
13 KiB
Python
367 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
|