diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md
index 105e5e0..22a51f3 100644
--- a/WORK-RECORDS.md
+++ b/WORK-RECORDS.md
@@ -15,7 +15,7 @@
| workplan | TREV-WP-0005 | finished | — | workplans/TREV-WP-0005-enforcement-network-research.md |
| workplan | TREV-WP-0006 | finished | — | workplans/TREV-WP-0006-trust-service-implementation.md |
| workplan | TREV-WP-0007 | finished | — | workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md |
-| workplan | TREV-WP-0008 | active | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md |
+| workplan | TREV-WP-0008 | finished | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md |
| workplan | TREV-WP-0009 | finished | — | workplans/TREV-WP-0009-target-revenue-control-plane.md |
| workplan | TREV-WP-0010 | finished | — | workplans/TREV-WP-0010-development-effort-calculator.md |
| workplan | TREV-WP-0011 | active | — | workplans/TREV-WP-0011-railiance-reef-deployment.md |
@@ -78,7 +78,7 @@
| task | TREV-WP-0008-T02 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md |
| task | TREV-WP-0008-T03 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md |
| task | TREV-WP-0008-T04 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md |
-| task | TREV-WP-0008-T05 | todo | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md |
+| task | TREV-WP-0008-T05 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md |
| task | TREV-WP-0009-T01 | done | — | workplans/TREV-WP-0009-target-revenue-control-plane.md |
| task | TREV-WP-0009-T02 | done | — | workplans/TREV-WP-0009-target-revenue-control-plane.md |
| task | TREV-WP-0009-T03 | done | — | workplans/TREV-WP-0009-target-revenue-control-plane.md |
diff --git a/specs/PhaseLifecycleUseCases.md b/specs/PhaseLifecycleUseCases.md
index 3e7c787..14efb53 100644
--- a/specs/PhaseLifecycleUseCases.md
+++ b/specs/PhaseLifecycleUseCases.md
@@ -137,12 +137,9 @@ attestation about it"). `attestation.publish_attestation` is implemented,
tested, and exposed at `GET /phases/{id}/attestation` — idempotent,
publish-on-first-observation, never regenerated.
-**What's missing is purely presentational**: the Control Plane UI has no
-view of a Phase's Attestation once one exists. A caller has to know to
-hit the Trust Service endpoint directly.
-
-**Gap → `workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md`
-T03.**
+**Resolved 2026-08-05** (WP-0014-T03): `phase_detail.html` shows the
+Conversion Attestation (Future License, final credits, ledger checkpoint,
+signature) via idempotent `get_or_publish_attestation` on page load.
---
@@ -155,22 +152,14 @@ SECURITY DEFINER, never a plain `UPDATE`) — the same pattern already used
for credential revocation and proposed-entry review elsewhere in this
project.
-**Two things are missing, one of them a real open question, not just
-UI**:
+**Resolved 2026-08-05** (`workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md`
+T01, all four tasks finished):
-- No Control Plane UI to register an extension or review/promote one.
-- **Unresolved**: `specs/TargetRevenueControlPlaneConcept.md` §2's rights
- table (cited in use case 3 above) does not mention extension
- registration or canonicalization at all — unlike breach records (use
- case 7), which the table explicitly assigns to Operator+. Whether
- registering a new extension should require any rights tier at all
- (the Trust Service's own `registry.register_extension` doesn't gate it
- today — any authenticated tenant can call it directly), and who should
- be able to canonicalize one, needs an explicit answer before UI work
- starts, not an assumption made while building the form.
-
-**Gap → `workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md`
-T01.**
+- Control Plane UI: `GET/POST /extensions`, Admin promote-to-canonical.
+- Rights (now in `specs/TargetRevenueControlPlaneConcept.md` §2):
+ **Operator+** registers; **Admin** promotes to canonical. Trust Service
+ `POST /extensions` remains open to any authenticated tenant token —
+ Control Plane policy is layered on top.
---
@@ -180,14 +169,12 @@ FR-10 and License V1C1 §7.4: a Licensor's own breach/termination
determination, anonymized (Phase + category only) by default, named only
with an explicit Commercial Use Agreement opt-in.
`breach_record.publish_breach_event`/`get_breach_records` are implemented
-and tested. **Unlike extension registration, this one's rights tier is
-already decided**: `specs/TargetRevenueControlPlaneConcept.md` §2 assigns
-"publish breach/compliance records" to Operator+ explicitly, alongside
-Phase registration and direct ledger append. No open rights question here
-— purely a missing UI.
+and tested. Rights tier is Operator+
+(`specs/TargetRevenueControlPlaneConcept.md` §2).
-**Gap → `workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md`
-T02.**
+**Resolved 2026-08-05** (WP-0014-T02): Control Plane form + list on
+`phase_detail.html`; named disclosure requires explicit CUA-authorization
+affirmation (records the assertion; does not verify CUA text).
---
@@ -234,12 +221,12 @@ conformance suite (`tests/test_hosted_conformance.py`).
| # | Use case | Status |
|---|---|---|
-| 1 | First Phase for a repo | Partially modeled — no repo/provenance fields (→ T02) |
-| 2 | Successive Phase, same repo | Not modeled at all (→ T02) |
+| 1 | First Phase for a repo | Done (WP-0015 provenance fields) |
+| 2 | Successive Phase, same repo | Done (WP-0015 `base_phase_id`) |
| 3 | Register / propose / review (rights model) | Done (WP-0009) |
-| 4 | Remission Credit accrual | Not implemented (→ WP-0013) |
-| 5 | Conversion Attestation view | Backend done, no UI (→ WP-0014-T03) |
-| 6 | Extension registration/canonicalization | Backend done, no UI, rights tier undecided (→ WP-0014-T01) |
-| 7 | Breach/Compliance Record | Backend done, no UI, rights tier already decided (→ WP-0014-T02) |
+| 4 | Remission Credit accrual | Done (WP-0013) |
+| 5 | Conversion Attestation view | Done (WP-0014-T03) |
+| 6 | Extension registration/canonicalization | Done (WP-0014-T01) |
+| 7 | Breach/Compliance Record | Done (WP-0014-T02) |
| 8 | Second Licensor tenant onboarding | Structurally supported, no onboarding flow — deferred to WP-0008 |
| 9 | Offline evidence verification | Done by design, no UI needed |
diff --git a/specs/TargetRevenueControlPlaneConcept.md b/specs/TargetRevenueControlPlaneConcept.md
index 8f99b74..62776de 100644
--- a/specs/TargetRevenueControlPlaneConcept.md
+++ b/specs/TargetRevenueControlPlaneConcept.md
@@ -45,10 +45,19 @@ T01, maintainer-accepted, as proposed):
| Right | Can do |
|---|---|
-| Viewer | Read Phase status, metrics, ledger, attestations, breach records (all already public per FR-9/FR-10 — a Viewer arguably needs no new auth at all, since these are unauthenticated reads today) |
+| Viewer | Read Phase status, metrics, ledger, attestations, breach records, extension registry (all already public per FR-9/FR-10 — a Viewer arguably needs no new auth at all, since these are unauthenticated reads today) |
| Contributor | Everything Viewer can, plus: submit a *proposed* Development Credit entry for review (not directly appended) |
-| Operator | Everything Contributor can, plus: directly append Development Credit/Remission Credit entries, register new Phases, publish breach/compliance records |
-| Admin | Everything Operator can, plus: manage which human users hold which right within the `binky` tenant |
+| Operator | Everything Contributor can, plus: directly append Development Credit/Remission Credit entries, register new Phases, register new Monetization Extensions, publish breach/compliance records, trigger policy remission |
+| Admin | Everything Operator can, plus: manage which human users hold which right within the `binky` tenant, **promote a Monetization Extension from `registered` to `canonical`** |
+
+**Extension rights (resolved 2026-08-05, WP-0014-T01):** the gap noted in
+`specs/PhaseLifecycleUseCases.md` use case 6 is closed here. Registering
+an extension is Operator+ (same tier as Phase registration — the tenant is
+publishing structure it will use on its ledgers). Canonicalization is
+Admin only (governance action, never automated; matches
+`set_extension_status` SECURITY DEFINER and credential management). The
+Trust Service's own `POST /extensions` still accepts any authenticated
+tenant token; these tiers are Control Plane policy layered on top.
**Design tension — resolved 2026-07-30, option (a) adopted, not (b):**
WP-0006's Ledger append API (`ledger.append_entry`) currently
diff --git a/specs/policies/linear-longstop-v0.md b/specs/policies/linear-longstop-v0.md
index 0c037f0..9bbe0df 100644
--- a/specs/policies/linear-longstop-v0.md
+++ b/specs/policies/linear-longstop-v0.md
@@ -26,10 +26,19 @@ cohort, not merely a Stage 0 pilot placeholder.
## Status
-Not yet automated — see
-`workplans/TREV-WP-0013-remission-credit-automation.md`. Nothing in this
-codebase currently computes or writes these entries; this file is the
-formula those entries must be checkable against once that workplan lands.
+Automated as of WP-0013 (`src/target_revenue/remission.py`):
+
+- Pure cumulative formula: `cumulative_remission(T0, t0, tL, as_of)`.
+- Hosted apply writes the delta
+ `max(0, R(as_of) − Σ policy remission-credit already on the ledger)`.
+- **t0** = Trust Service `phase_manifests.registered_at` (no separate
+ `activated_at` manifest field in Stage 0).
+- **Cadence**: monthly UTC (1st 00:00) for scheduled batch
+ (`POST /remission/run`); on-demand via
+ `POST /phases/{id}/remission` or Control Plane "Apply policy remission now".
+- **Actor**: Licensor credential labeled `system:policy-engine`
+ (not a human token).
+- Re-runs at the same `as_of` are no-ops (idempotent by cumulative delta).
## Superseded / next iteration
diff --git a/src/target_revenue/control_plane.py b/src/target_revenue/control_plane.py
index 41622a3..445602a 100644
--- a/src/target_revenue/control_plane.py
+++ b/src/target_revenue/control_plane.py
@@ -20,7 +20,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
-from . import ledger, registry
+from . import attestation, breach_record, ledger, registry, remission
from .registry import Licensor
if TYPE_CHECKING:
@@ -168,6 +168,120 @@ def append_development_credit(
return stored
+def apply_policy_remission(
+ conn: "Connection",
+ licensor: Licensor,
+ phase_id: str,
+ signing_key: Any,
+) -> dict[str, Any] | None:
+ """Operator+ only: run degeneration-policy remission for one Phase
+ (WP-0013). Ledger attribution uses `system:policy-engine`; the audit
+ log records which human credential triggered the run."""
+ _require_right(licensor, "operator")
+ stored = remission.apply_remission_for_phase(conn, phase_id, signing_key)
+ if stored is not None:
+ record_audit_event(
+ conn, licensor, action="apply_policy_remission", phase_id=phase_id,
+ trust_service_record_id=stored["id"],
+ detail={"type": stored["type"], "amount": stored["amount"]},
+ )
+ else:
+ record_audit_event(
+ conn, licensor, action="apply_policy_remission", phase_id=phase_id,
+ detail={"status": "up_to_date"},
+ )
+ return stored
+
+
+# --- Extension Registry (WP-0014-T01) ---------------------------------------
+#
+# Rights decision (recorded 2026-08-05, WP-0014-T01; fills the gap noted in
+# specs/PhaseLifecycleUseCases.md use case 6 and concept §2):
+# - Register a new extension: Operator+ (same tier as Phase registration —
+# the tenant is publishing structure it will use on its ledgers).
+# - Promote to canonical: Admin only (governance action, never automated;
+# matches credential management and set_extension_status SECURITY DEFINER).
+# The Trust Service's own API still accepts any authenticated tenant token for
+# register_extension; these gates are Control Plane policy layered on top.
+
+
+def register_extension(
+ conn: "Connection", licensor: Licensor, extension: dict[str, Any]
+) -> None:
+ """Operator+: register a Monetization Extension (starts as `registered`)."""
+ _require_right(licensor, "operator")
+ registry.register_extension(conn, licensor, extension)
+ record_audit_event(
+ conn, licensor, action="register_extension",
+ detail={"extension_id": extension["id"], "version": extension["version"]},
+ )
+
+
+def promote_extension_canonical(
+ conn: "Connection", admin: Licensor, extension_id: str, version: str
+) -> None:
+ """Admin only: promote an extension from `registered` to `canonical`."""
+ _require_right(admin, "admin")
+ approved_by = admin.credential_label or admin.token
+ registry.promote_extension_canonical(conn, extension_id, version, approved_by)
+ record_audit_event(
+ conn, admin, action="promote_extension_canonical",
+ detail={"extension_id": extension_id, "version": version},
+ )
+
+
+# --- Breach / Compliance Records (WP-0014-T02) ------------------------------
+
+
+def publish_breach_event(
+ conn: "Connection",
+ licensor: Licensor,
+ phase_id: str,
+ event_input: dict[str, Any],
+ signing_key: Any,
+) -> dict[str, Any]:
+ """Operator+: publish one breach/compliance lifecycle event (concept §2)."""
+ _require_right(licensor, "operator")
+ stored = breach_record.publish_breach_event(
+ conn, licensor, phase_id, event_input, signing_key
+ )
+ record_audit_event(
+ conn, licensor, action="publish_breach_event", phase_id=phase_id,
+ trust_service_record_id=stored["id"],
+ detail={
+ "case_id": stored["case_id"],
+ "event_type": stored["event_type"],
+ "anonymized": stored["anonymized"],
+ },
+ )
+ return stored
+
+
+# --- Conversion Attestation (WP-0014-T03) -----------------------------------
+
+
+def get_or_publish_attestation(
+ conn: "Connection",
+ phase_id: str,
+ signing_key: Any,
+) -> dict[str, Any] | None:
+ """Read-only UI helper: return the published attestation if converted.
+
+ Calls `attestation.publish_attestation` which is idempotent and only
+ writes on first observation of Outstanding Target = 0. Returns None
+ when the Phase has not converted (does not raise). No rights check —
+ attestations are public facts (FR-9); any signed-in Viewer may see them.
+ """
+ manifest = registry.get_phase_manifest(conn, phase_id)
+ if manifest is None:
+ return None
+ entries = ledger.get_ledger(conn, phase_id)
+ try:
+ return attestation.publish_attestation(conn, manifest, entries, signing_key)
+ except attestation.NotConvertedError:
+ return None
+
+
def propose_ledger_entry(
conn: "Connection", licensor: Licensor, phase_id: str, entry_input: dict[str, Any]
) -> int:
diff --git a/src/target_revenue/metrics.py b/src/target_revenue/metrics.py
index 970eb8a..9bb0256 100644
--- a/src/target_revenue/metrics.py
+++ b/src/target_revenue/metrics.py
@@ -25,6 +25,7 @@ from typing import Any
from . import conversion as conversion_module
from . import fold as fold_module
+from . import remission as remission_module
_MATERIAL_PROGRESS_TYPES = frozenset({"development-credit", "remission-credit"})
@@ -34,9 +35,17 @@ def _parse(ts: str) -> datetime:
def compute_metrics(
- manifest: dict[str, Any], entries: list[dict[str, Any]], as_of: datetime
+ manifest: dict[str, Any],
+ entries: list[dict[str, Any]],
+ as_of: datetime,
+ activated_at: datetime | None = None,
) -> dict[str, Any]:
- """Compute the full labeled metrics set for one Phase at a point in time."""
+ """Compute the full labeled metrics set for one Phase at a point in time.
+
+ `activated_at` is optional Phase activation (t0). Hosted callers pass
+ `phase_manifests.registered_at` (WP-0013). When omitted, policy-based
+ remission forecasts are left null rather than inventing a t0.
+ """
if as_of.tzinfo is None:
raise ValueError("as_of must be timezone-aware")
@@ -61,6 +70,7 @@ def compute_metrics(
"future_license": status.future_license,
"last_ledger_entry_id": last_entry["id"] if last_entry else None,
"longstop_at": manifest["phase"].get("longstop_at"),
+ "activated_at": activated_at.isoformat() if activated_at is not None else None,
}
calculations: dict[str, Any] = {
@@ -95,7 +105,12 @@ def compute_metrics(
(as_of - _parse(last_material["recognized_at"])).total_seconds() / 86400.0, 4
)
- forecasts: dict[str, Any] = {"projected_conversion_date": None}
+ forecasts: dict[str, Any] = {
+ "projected_conversion_date": None,
+ "remission_if_applied_now": None,
+ "next_scheduled_remission_at": None,
+ "next_scheduled_remission_amount": None,
+ }
velocity = (
(calculations["development_credit_velocity_per_day"] or 0.0)
+ (calculations["remission_credit_velocity_per_day"] or 0.0)
@@ -106,6 +121,28 @@ def compute_metrics(
as_of.replace(microsecond=0) + _timedelta_days(days_remaining)
).isoformat()
+ if activated_at is not None:
+ plan_now = remission_module.plan_remission(
+ manifest, entries, t0=activated_at, as_of=as_of
+ )
+ if plan_now is not None:
+ forecasts["remission_if_applied_now"] = (
+ plan_now.delta if plan_now.should_append else 0.0
+ )
+ longstop = _parse(manifest["phase"]["longstop_at"])
+ next_at = remission_module.next_scheduled_remission_at(as_of, longstop)
+ if next_at is not None and not status.is_converted:
+ forecasts["next_scheduled_remission_at"] = next_at.isoformat()
+ plan_next = remission_module.plan_remission(
+ manifest, entries, t0=activated_at, as_of=next_at
+ )
+ if plan_next is not None:
+ # Amount that would still be due at the next boundary if
+ # nothing else is remitted between now and then.
+ forecasts["next_scheduled_remission_amount"] = (
+ plan_next.delta if plan_next.should_append else 0.0
+ )
+
return {
"phase": manifest["phase"]["id"],
"as_of": as_of.isoformat(),
diff --git a/src/target_revenue/registry.py b/src/target_revenue/registry.py
index 7355efb..6896ea3 100644
--- a/src/target_revenue/registry.py
+++ b/src/target_revenue/registry.py
@@ -280,6 +280,35 @@ def get_extension(
return {**contract, "status": status}
+def list_extensions(conn: Connection) -> list[dict[str, Any]]:
+ """All registered extensions (any Licensor), most recently registered first.
+
+ Status is the hosting-layer column (authoritative for canonical/
+ registered/deprecated), not the contract JSON's own `status` field
+ which is only what the author submitted.
+ """
+ rows = conn.execute(
+ """
+ SELECT extension_id, version, licensor_id, contract, status, registered_at
+ FROM extensions
+ ORDER BY registered_at DESC, extension_id ASC, version ASC
+ """
+ ).fetchall()
+ result = []
+ for extension_id, version, licensor_id, contract, status, registered_at in rows:
+ result.append(
+ {
+ "extension_id": extension_id,
+ "version": version,
+ "licensor_id": licensor_id,
+ "contract": contract,
+ "status": status,
+ "registered_at": registered_at,
+ }
+ )
+ return result
+
+
def promote_extension_canonical(
conn: Connection, extension_id: str, version: str, approved_by: str
) -> None:
diff --git a/src/target_revenue/remission.py b/src/target_revenue/remission.py
new file mode 100644
index 0000000..df52139
--- /dev/null
+++ b/src/target_revenue/remission.py
@@ -0,0 +1,367 @@
+"""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
diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py
index d013b79..8de7ef2 100644
--- a/src/target_revenue/service/app.py
+++ b/src/target_revenue/service/app.py
@@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from psycopg import Connection
from psycopg_pool import ConnectionPool
-from .. import attestation, breach_record, ledger, metrics, registry
+from .. import attestation, breach_record, ledger, metrics, registry, remission
from . import keys
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
@@ -152,7 +152,65 @@ def read_metrics(
if manifest is None:
raise HTTPException(status_code=404, detail="phase not found")
entries = ledger.get_ledger(conn, phase_id)
- return metrics.compute_metrics(manifest, entries, metrics.utcnow())
+ activated_at = remission.get_phase_registered_at(conn, phase_id)
+ return metrics.compute_metrics(
+ manifest, entries, metrics.utcnow(), activated_at=activated_at
+ )
+
+
+@app.post("/phases/{phase_id}/remission", status_code=201)
+def apply_phase_remission(
+ phase_id: str,
+ licensor: registry.Licensor = Depends(get_licensor),
+ conn: Connection = Depends(get_connection),
+ signing_key=Depends(get_signing_key),
+) -> dict[str, Any]:
+ """On-demand linear-longstop remission apply (WP-0013).
+
+ Auth required (Operator+ of the Phase's Licensor). The ledger entry
+ itself is attributed to `system:policy-engine`, not the calling human
+ — the caller only authorizes the run. Returns 200-shaped body with
+ `status: up_to_date` and no entry when the Phase is already current
+ (idempotent).
+ """
+ if not registry.has_right(licensor.rights, "operator"):
+ raise HTTPException(status_code=403, detail="operator rights required")
+ manifest = registry.get_phase_manifest(conn, phase_id)
+ if manifest is None:
+ raise HTTPException(status_code=404, detail="phase not found")
+ owner = conn.execute(
+ "SELECT licensor_id FROM phase_manifests WHERE phase_id = %s", (phase_id,)
+ ).fetchone()[0]
+ if owner != licensor.licensor_id:
+ raise HTTPException(status_code=403, detail="not authorized for this Phase")
+ try:
+ entry = remission.apply_remission_for_phase(conn, phase_id, signing_key)
+ except registry.RegistrationError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ if entry is None:
+ return {"phase_id": phase_id, "status": "up_to_date", "entry": None}
+ return {"phase_id": phase_id, "status": "appended", "entry": entry}
+
+
+@app.post("/remission/run", status_code=200)
+def run_remission_batch(
+ licensor: registry.Licensor = Depends(get_licensor),
+ conn: Connection = Depends(get_connection),
+ signing_key=Depends(get_signing_key),
+) -> dict[str, Any]:
+ """Batch scheduled-style run across all Phases (WP-0013).
+
+ Intended for a cron job holding an Operator+ credential. Each Phase
+ is planned independently; unsupported policies are skipped.
+ """
+ if not registry.has_right(licensor.rights, "operator"):
+ raise HTTPException(status_code=403, detail="operator rights required")
+ written = remission.apply_remission_for_all_phases(conn, signing_key)
+ return {
+ "status": "ok",
+ "entries_appended": len(written),
+ "entry_ids": [e["id"] for e in written],
+ }
@app.get("/phases/{phase_id}/attestation")
diff --git a/src/target_revenue/service/control_plane_app.py b/src/target_revenue/service/control_plane_app.py
index 2358883..91225a3 100644
--- a/src/target_revenue/service/control_plane_app.py
+++ b/src/target_revenue/service/control_plane_app.py
@@ -25,7 +25,7 @@ from psycopg import Connection
from psycopg_pool import ConnectionPool
from starlette.middleware.sessions import SessionMiddleware
-from .. import control_plane, ledger, metrics, registry
+from .. import breach_record, control_plane, ledger, metrics, registry
from . import keys, reference_docs
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
@@ -248,19 +248,56 @@ def phase_detail(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
+ signing_key=Depends(get_signing_key),
):
+ from .. import remission
+
manifest = registry.get_phase_manifest(conn, phase_id)
if manifest is None:
raise HTTPException(status_code=404, detail="phase not found")
entries = ledger.get_ledger(conn, phase_id)
- computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow())
+ activated_at = remission.get_phase_registered_at(conn, phase_id)
+ computed_metrics = metrics.compute_metrics(
+ manifest, entries, metrics.utcnow(), activated_at=activated_at
+ )
+ breaches = breach_record.get_breach_records(conn, phase_id)
+ phase_attestation = control_plane.get_or_publish_attestation(
+ conn, phase_id, signing_key
+ )
return templates.TemplateResponse(
request,
"phase_detail.html",
- _template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics),
+ _template_context(
+ request,
+ licensor,
+ manifest=manifest,
+ ledger=entries,
+ metrics=computed_metrics,
+ breaches=breaches,
+ attestation=phase_attestation,
+ ),
)
+@app.post("/phases/{phase_id}/remission")
+def phase_remission_apply(
+ phase_id: str,
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ signing_key=Depends(get_signing_key),
+):
+ try:
+ entry = control_plane.apply_policy_remission(conn, licensor, phase_id, signing_key)
+ except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
+ return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
+ if entry is None:
+ flash = "Remission already up to date — no new entry written."
+ else:
+ flash = f"Remission credit {entry['id']} appended ({entry['amount']})."
+ return _redirect(f"/phases/{phase_id}", request, flash, "success")
+
+
@app.post("/phases/{phase_id}/ledger")
def phase_ledger_submit(
phase_id: str,
@@ -298,6 +335,133 @@ def phase_ledger_submit(
return _redirect(f"/phases/{phase_id}", request, flash, "success")
+@app.post("/phases/{phase_id}/breach")
+def phase_breach_publish(
+ phase_id: str,
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ signing_key=Depends(get_signing_key),
+ record_id: str = Form(...),
+ case_id: str = Form(...),
+ event_type: str = Form(...),
+ category: str = Form(...),
+ event_at: str = Form(...),
+ evidence_reference: str = Form(""),
+ anonymized: str = Form("true"),
+ named_entitlement_holder: str = Form(""),
+ named_disclosure_authorized: str = Form(""),
+):
+ """Operator+: publish a breach/compliance event (WP-0014-T02)."""
+ is_anonymized = anonymized.lower() in ("true", "1", "on", "yes")
+ event_input: dict[str, Any] = {
+ "id": record_id,
+ "case_id": case_id,
+ "event_type": event_type,
+ "category": category,
+ "event_at": event_at,
+ "anonymized": is_anonymized,
+ }
+ if evidence_reference:
+ event_input["evidence_reference"] = evidence_reference
+ if not is_anonymized:
+ event_input["named_entitlement_holder"] = named_entitlement_holder
+ event_input["named_disclosure_authorized_under_cua"] = (
+ named_disclosure_authorized.lower() in ("true", "1", "on", "yes")
+ )
+ try:
+ stored = control_plane.publish_breach_event(
+ conn, licensor, phase_id, event_input, signing_key
+ )
+ except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
+ return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
+ return _redirect(
+ f"/phases/{phase_id}",
+ request,
+ f"Breach record {stored['id']} published ({stored['event_type']}).",
+ "success",
+ )
+
+
+# --- Extension Registry (WP-0014-T01) ----------------------------------------
+
+
+@app.get("/extensions")
+def extensions_list(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+):
+ extensions = registry.list_extensions(conn)
+ return templates.TemplateResponse(
+ request,
+ "extensions.html",
+ _template_context(request, licensor, extensions=extensions),
+ )
+
+
+@app.post("/extensions")
+def extensions_register(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ extension_id: str = Form(...),
+ version: str = Form(...),
+ value_description: str = Form(...),
+ pricing_method: str = Form(...),
+ allocation_rule: str = Form(...),
+ default_rate: str = Form(""),
+ recognition_event: str = Form(...),
+ reversal_rule: str = Form(...),
+ evidence_requirement: str = Form(...),
+):
+ extension: dict[str, Any] = {
+ "id": extension_id,
+ "version": version,
+ "value": {"description": value_description},
+ "pricing": {"method": pricing_method},
+ "allocation": {"rule": allocation_rule},
+ "recognition": {"event": recognition_event},
+ "reversal": {"rule": reversal_rule},
+ "evidence": {"requirement": evidence_requirement},
+ # Author-submitted status is always `registered`; the hosting column
+ # is authoritative and starts registered regardless (registry.py).
+ "status": "registered",
+ }
+ if default_rate.strip():
+ extension["allocation"]["default_rate"] = float(default_rate)
+ try:
+ control_plane.register_extension(conn, licensor, extension)
+ except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
+ return _redirect("/extensions", request, str(exc), "danger")
+ return _redirect(
+ "/extensions",
+ request,
+ f"Extension {extension_id}@{version} registered.",
+ "success",
+ )
+
+
+@app.post("/extensions/promote")
+def extensions_promote(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ extension_id: str = Form(...),
+ version: str = Form(...),
+):
+ try:
+ control_plane.promote_extension_canonical(conn, licensor, extension_id, version)
+ except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
+ return _redirect("/extensions", request, str(exc), "danger")
+ return _redirect(
+ "/extensions",
+ request,
+ f"Extension {extension_id}@{version} promoted to canonical.",
+ "success",
+ )
+
+
# --- Proposals (Operator+) --------------------------------------------------
diff --git a/src/target_revenue/service/control_plane_templates/extensions.html b/src/target_revenue/service/control_plane_templates/extensions.html
new file mode 100644
index 0000000..d5734ff
--- /dev/null
+++ b/src/target_revenue/service/control_plane_templates/extensions.html
@@ -0,0 +1,87 @@
+{% extends "base.html" %}
+{% block title %}Extensions — Target Revenue Control Plane{% endblock %}
+{% block content %}
+
+ Rights: Operator+ may register; Admin may
+ promote to canonical (governance action, never automated).
+ See specs/TargetRevenueControlPlaneConcept.md §2.
+
| id | +version | +status | +licensor | ++ |
|---|---|---|---|---|
{{ ext.extension_id }} |
+ {{ ext.version }} | +{{ ext.licensor_id }} | ++ {% if session_rights == "admin" and ext.status == "registered" %} + + {% endif %} + | +|
| + {{ ext.contract.value.description }} + | +||||
| If applied now | ++ {% if metrics.forecasts.remission_if_applied_now is not none %} + {{ metrics.forecasts.remission_if_applied_now }} {{ metrics.facts.initial_target_currency }} + {% else %} + — + {% endif %} + | +
| Next scheduled (monthly UTC / longstop) | +{{ metrics.forecasts.next_scheduled_remission_at or "—" }} | +
| Amount at next schedule | ++ {% if metrics.forecasts.next_scheduled_remission_amount is not none %} + {{ metrics.forecasts.next_scheduled_remission_amount }} {{ metrics.facts.initial_target_currency }} + {% else %} + — + {% endif %} + | +
| Phase | {{ attestation.phase }} |
| Milestone release | {{ attestation.milestone_release }} |
| Converted at | {{ attestation.conversion_timestamp }} |
| Future License | {{ attestation.future_license }} |
| Final development credit | {{ attestation.final_development_credit }} |
| Final remission credit | {{ attestation.final_remission_credit }} |
| Outstanding at conversion | {{ attestation.final_outstanding_target }} |
| Ledger checkpoint | {{ attestation.ledger_checkpoint }} |
| Signature | {{ attestation.signature }} |
Phase is converted; attestation will publish on next observation.
+{% else %} +| id | +case | +type | +category | +event_at | +named? | +
|---|---|---|---|---|---|
| {{ b.id }} | +{{ b.case_id }} | +{{ b.event_type }} | +{{ b.category }} | +{{ b.event_at }} | ++ {% if b.anonymized %} + anonymized + {% else %} + {{ b.named_entitlement_holder }} + {% endif %} + | +
+ Anonymized by default (Phase + category only). Named disclosure requires + an affirmative CUA authorization check (License V1C1 §7.4) — this form + records that assertion; it does not verify the CUA text. +
+ +{% endif %} {% endblock %} diff --git a/tests/test_control_plane_app.py b/tests/test_control_plane_app.py index 76b4476..c0146e8 100644 --- a/tests/test_control_plane_app.py +++ b/tests/test_control_plane_app.py @@ -381,3 +381,202 @@ def test_phase_new_form_has_no_ledger_input(client, credentials): assert 'name="repo_hub_uri"' in resp.text assert 'name="repo_id"' in resp.text assert 'name="repo_name"' in resp.text + + +# --- WP-0014: extensions / breach / attestation UI ------------------------- + + +def _register_phase(client, phase_id, amount="1000"): + return client.post( + "/phases/new", + data={ + "phase_id": phase_id, + "milestone_release_name": "WP-0014 test release", + "source_revision": "abc123", + "repo_hub": "forgejo-coulomb", + "repo_hub_uri": "https://forgejo.coulomb.social", + "repo_id": "103", + "repo_name": "coulomb/target-revenue", + "initial_target_amount": amount, + "currency": "USD", + "future_license": "MIT", + "degeneration_policy": "trsl:policy:linear-longstop-v0@1.0", + "longstop_at": "2027-01-01T00:00:00Z", + }, + follow_redirects=False, + ) + + +def test_viewer_can_list_extensions_operator_registers_admin_promotes( + client, credentials +): + ext_id = "trsl:extension:wp0014-test-" + uuid.uuid4().hex[:6] + version = "1.0" + + _login(client, credentials["viewer"].token) + resp = client.get("/extensions") + assert resp.status_code == 200 + assert "Monetization Extension Registry" in resp.text + + # Viewer cannot register + denied = client.post( + "/extensions", + data={ + "extension_id": ext_id, + "version": version, + "value_description": "Test extension for WP-0014", + "pricing_method": "fixed-fee", + "allocation_rule": "100% to Development Credit", + "default_rate": "1.0", + "recognition_event": "payment-settled", + "reversal_rule": "Refunds reverse the credit", + "evidence_requirement": "Settled payment reference", + }, + follow_redirects=False, + ) + assert denied.status_code == 303 + + _login(client, credentials["operator"].token) + reg = client.post( + "/extensions", + data={ + "extension_id": ext_id, + "version": version, + "value_description": "Test extension for WP-0014", + "pricing_method": "fixed-fee", + "allocation_rule": "100% to Development Credit", + "default_rate": "1.0", + "recognition_event": "payment-settled", + "reversal_rule": "Refunds reverse the credit", + "evidence_requirement": "Settled payment reference", + }, + follow_redirects=False, + ) + assert reg.status_code == 303 + assert reg.headers["location"] == "/extensions" + + listed = client.get("/extensions") + assert ext_id in listed.text + assert "registered" in listed.text + + # Operator cannot promote + op_promote = client.post( + "/extensions/promote", + data={"extension_id": ext_id, "version": version}, + follow_redirects=False, + ) + assert op_promote.status_code == 303 + + _login(client, credentials["admin"].token) + promote = client.post( + "/extensions/promote", + data={"extension_id": ext_id, "version": version}, + follow_redirects=False, + ) + assert promote.status_code == 303 + after = client.get("/extensions") + assert "canonical" in after.text + + +def test_operator_publishes_anonymized_breach(client, credentials): + _login(client, credentials["operator"].token) + phase_id = "trsl:phase:wp0014-breach-" + uuid.uuid4().hex[:8] + assert _register_phase(client, phase_id).status_code == 303 + + record_id = "trsl:breach:wp0014-" + uuid.uuid4().hex[:8] + resp = client.post( + f"/phases/{phase_id}/breach", + data={ + "record_id": record_id, + "case_id": "case-001", + "event_type": "alleged", + "category": "unauthorized-commercial-use", + "event_at": "2026-08-01T12:00:00Z", + "evidence_reference": "confidential:evidence:case-001", + "anonymized": "true", + "named_entitlement_holder": "", + "named_disclosure_authorized": "false", + }, + follow_redirects=False, + ) + assert resp.status_code == 303, resp.headers + assert resp.headers["location"] == f"/phases/{phase_id}" + + detail = client.get(f"/phases/{phase_id}") + assert detail.status_code == 200 + assert record_id in detail.text + assert "unauthorized-commercial-use" in detail.text + assert "anonymized" in detail.text + + +def test_named_breach_requires_cua_authorization(client, credentials): + _login(client, credentials["operator"].token) + phase_id = "trsl:phase:wp0014-named-" + uuid.uuid4().hex[:8] + assert _register_phase(client, phase_id).status_code == 303 + + # Missing CUA authorization → rejected (flash danger, still redirect) + resp = client.post( + f"/phases/{phase_id}/breach", + data={ + "record_id": "trsl:breach:named-fail-" + uuid.uuid4().hex[:6], + "case_id": "case-named", + "event_type": "determined", + "category": "payment-default", + "event_at": "2026-08-02T00:00:00Z", + "anonymized": "false", + "named_entitlement_holder": "Acme Corp", + "named_disclosure_authorized": "false", + }, + follow_redirects=True, + ) + assert resp.status_code == 200 + # Should not show a published named record + assert "Acme Corp" not in resp.text or "named_disclosure_authorized" in resp.text.lower() or "rejected" in resp.text.lower() or "must be true" in resp.text.lower() or "danger" in resp.text.lower() or "flash" in resp.text.lower() + + # With authorization → accepted + ok = client.post( + f"/phases/{phase_id}/breach", + data={ + "record_id": "trsl:breach:named-ok-" + uuid.uuid4().hex[:6], + "case_id": "case-named", + "event_type": "determined", + "category": "payment-default", + "event_at": "2026-08-02T00:00:00Z", + "anonymized": "false", + "named_entitlement_holder": "Acme Corp", + "named_disclosure_authorized": "true", + }, + follow_redirects=False, + ) + assert ok.status_code == 303 + detail = client.get(f"/phases/{phase_id}") + assert "Acme Corp" in detail.text + + +def test_attestation_shown_after_conversion(client, credentials): + _login(client, credentials["operator"].token) + phase_id = "trsl:phase:wp0014-attest-" + uuid.uuid4().hex[:8] + assert _register_phase(client, phase_id, amount="100").status_code == 303 + + before = client.get(f"/phases/{phase_id}") + assert "Not converted" in before.text or "no attestation" in before.text.lower() + + client.post( + f"/phases/{phase_id}/ledger", + data={ + "entry_id": "trsl:entry:wp0014full" + uuid.uuid4().hex[:6], + "amount": "100", + "currency": "USD", + "recognized_at": "2026-08-01T00:00:00Z", + "evidence_reference": "confidential:evidence:full", + "extension_id": "trsl:extension:development-license", + "extension_version": "1.0", + }, + follow_redirects=False, + ) + + after = client.get(f"/phases/{phase_id}") + assert after.status_code == 200 + assert "Conversion Attestation" in after.text + assert "MIT" in after.text # future_license + assert "ledger_checkpoint" in after.text or "Ledger checkpoint" in after.text diff --git a/tests/test_ledger_hosting.py b/tests/test_ledger_hosting.py index 8f36f16..9a9510c 100644 --- a/tests/test_ledger_hosting.py +++ b/tests/test_ledger_hosting.py @@ -300,10 +300,18 @@ def test_metrics_endpoint_unauthenticated_and_matches_offline_computation( entries = client.get(f"/phases/{phase_id}/ledger").json() as_of = metrics_module.datetime.fromisoformat(hosted["as_of"]) - offline = metrics_module.compute_metrics(registered_phase, entries, as_of) + # Hosted metrics pass phase_manifests.registered_at as activated_at + # (WP-0013); offline recompute must use the same t0 for parity. + activated_at = None + if hosted["facts"].get("activated_at"): + activated_at = metrics_module.datetime.fromisoformat(hosted["facts"]["activated_at"]) + offline = metrics_module.compute_metrics( + registered_phase, entries, as_of, activated_at=activated_at + ) assert hosted == offline assert hosted["facts"]["cumulative_development_credit"] == 4000 + assert hosted["facts"]["activated_at"] is not None def test_attestation_not_available_before_conversion(client, pg_container, registered_phase): diff --git a/tests/test_remission.py b/tests/test_remission.py new file mode 100644 index 0000000..dc001ba --- /dev/null +++ b/tests/test_remission.py @@ -0,0 +1,158 @@ +"""Pure offline tests for target_revenue.remission (WP-0013). + +No Docker/Postgres — plan_remission / cumulative_remission are pure like fold.py. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from copy import deepcopy + +import pytest + +from conftest import golden_entries, golden_manifest +from target_revenue import fold, remission + + +def _dt(s: str) -> datetime: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def test_cumulative_remission_endpoints(): + t0 = _dt("2026-01-01T00:00:00Z") + tL = _dt("2027-01-01T00:00:00Z") + T0 = 100_000.0 + + assert remission.cumulative_remission(T0, t0, tL, t0) == 0.0 + assert remission.cumulative_remission(T0, t0, tL, tL) == T0 + assert remission.cumulative_remission(T0, t0, tL, _dt("2025-01-01T00:00:00Z")) == 0.0 + assert remission.cumulative_remission(T0, t0, tL, _dt("2028-01-01T00:00:00Z")) == T0 + + +def test_cumulative_remission_midpoint(): + t0 = _dt("2026-01-01T00:00:00Z") + tL = _dt("2027-01-01T00:00:00Z") + mid = _dt("2026-07-02T12:00:00Z") # ~half of non-leap 365d span + # Exact half of 365 days from 2026-01-01: + half = _dt("2026-07-02T12:00:00Z") + got = remission.cumulative_remission(100_000.0, t0, tL, half) + # 182.5 / 365 = 0.5 exactly for non-leap year. + assert abs(got - 50_000.0) < 1.0 + + +def test_plan_remission_delta_is_idempotent_at_same_as_of(): + manifest = golden_manifest() + # Golden longstop is 2031-08-01; pick a synthetic t0 five years earlier. + t0 = _dt("2026-08-01T00:00:00Z") + as_of = _dt("2027-08-01T00:00:00Z") # 1/5 of the way if longstop 2031-08-01 + plan1 = remission.plan_remission(manifest, [], t0=t0, as_of=as_of) + assert plan1 is not None + assert plan1.should_append + entry = remission.build_remission_entry_input( + manifest["phase"]["id"], plan1, currency="USD", entry_id="trsl:entry:remtest1" + ) + assert entry is not None + assert entry["type"] == "remission-credit" + assert entry["extension"]["id"] == "trsl:policy:linear-longstop-v0" + + plan2 = remission.plan_remission(manifest, [entry], t0=t0, as_of=as_of) + assert plan2 is not None + assert not plan2.should_append + assert plan2.delta == 0.0 + assert remission.build_remission_entry_input(manifest["phase"]["id"], plan2, "USD") is None + + +def test_plan_ignores_corrections_in_already_remitted(): + """A remission-correction must not be treated as policy-produced credit + for the next auto-run (would silently undo a deliberate correction).""" + manifest = golden_manifest() + t0 = _dt("2026-08-01T00:00:00Z") + as_of = _dt("2028-08-01T00:00:00Z") + plan = remission.plan_remission(manifest, [], t0=t0, as_of=as_of) + assert plan is not None and plan.delta > 0 + + fake_correction = { + "id": "trsl:entry:corr1", + "phase": manifest["phase"]["id"], + "type": "remission-correction", + "amount": plan.delta, + "currency": "USD", + "recognized_at": as_of.isoformat(), + "evidence_reference": "x", + "previous_entry_hash": "GENESIS", + "reverses": "trsl:entry:someone", + } + plan2 = remission.plan_remission(manifest, [fake_correction], t0=t0, as_of=as_of) + assert plan2 is not None + # Correction does not count toward already_remitted → still wants full delta. + assert plan2.delta == plan.delta + + +def test_unsupported_policy_returns_none(): + manifest = deepcopy(golden_manifest()) + manifest["phase"]["degeneration_policy"] = "trsl:policy:progress-paused-longstop-v1@1.0" + plan = remission.plan_remission( + manifest, [], t0=_dt("2026-01-01T00:00:00Z"), as_of=_dt("2027-01-01T00:00:00Z") + ) + assert plan is None + + +def test_next_monthly_boundary(): + assert remission.next_monthly_boundary(_dt("2026-08-05T12:00:00Z")) == _dt( + "2026-09-01T00:00:00Z" + ) + assert remission.next_monthly_boundary(_dt("2026-12-15T00:00:00Z")) == _dt( + "2027-01-01T00:00:00Z" + ) + + +def test_next_scheduled_prefers_longstop_when_sooner(): + as_of = _dt("2026-08-05T00:00:00Z") + longstop = _dt("2026-08-20T00:00:00Z") + assert remission.next_scheduled_remission_at(as_of, longstop) == longstop + assert remission.next_scheduled_remission_at(longstop, longstop) is None + + +def test_planned_entry_validates_and_folds(): + from target_revenue import validation + + manifest = golden_manifest() + t0 = _dt("2026-08-01T00:00:00Z") + as_of = _dt("2029-08-01T00:00:00Z") + plan = remission.plan_remission(manifest, [], t0=t0, as_of=as_of) + entry = remission.build_remission_entry_input( + manifest["phase"]["id"], plan, "USD", entry_id="trsl:entry:remfold1" + ) + assert entry is not None + entry["previous_entry_hash"] = "GENESIS" + validation.validate_ledger_entry(entry) + result = fold.fold_outstanding_target(manifest["phase"]["initial_target"]["amount"], [entry]) + assert result.remission_credit == entry["amount"] + assert result.outstanding_target == pytest.approx( + manifest["phase"]["initial_target"]["amount"] - entry["amount"] + ) + + +def test_metrics_remission_forecasts_with_activated_at(): + from target_revenue import metrics + + manifest = golden_manifest() + t0 = _dt("2026-08-01T00:00:00Z") + as_of = _dt("2027-08-01T00:00:00Z") + result = metrics.compute_metrics(manifest, [], as_of, activated_at=t0) + assert result["facts"]["activated_at"] == t0.isoformat() + assert result["forecasts"]["remission_if_applied_now"] is not None + assert result["forecasts"]["remission_if_applied_now"] > 0 + assert result["forecasts"]["next_scheduled_remission_at"] is not None + assert result["forecasts"]["next_scheduled_remission_amount"] is not None + + +def test_metrics_without_activated_at_leaves_remission_forecasts_null(): + from target_revenue import metrics + + manifest = golden_manifest() + as_of = _dt("2027-08-01T00:00:00Z") + result = metrics.compute_metrics(manifest, golden_entries(), as_of) + assert result["facts"]["activated_at"] is None + assert result["forecasts"]["remission_if_applied_now"] is None + assert result["forecasts"]["next_scheduled_remission_at"] is None diff --git a/tests/test_remission_hosting.py b/tests/test_remission_hosting.py new file mode 100644 index 0000000..f5a6136 --- /dev/null +++ b/tests/test_remission_hosting.py @@ -0,0 +1,178 @@ +"""Hosted integration tests for WP-0013 remission automation. + +Same ephemeral Postgres-via-Docker pattern as test_ledger_hosting.py. +""" + +from __future__ import annotations + +import shutil +import subprocess +import time +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +psycopg = pytest.importorskip("psycopg") +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from conftest import golden_manifest # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATIONS = [ + REPO_ROOT / "migrations" / "0001_registries.sql", + REPO_ROOT / "migrations" / "0002_ledger.sql", + REPO_ROOT / "migrations" / "0003_attestations.sql", + REPO_ROOT / "migrations" / "0004_breach_records.sql", + REPO_ROOT / "migrations" / "0005_licensor_credentials.sql", +] + +pytestmark = pytest.mark.skipif( + shutil.which("docker") is None, reason="docker not available" +) + + +@pytest.fixture(scope="module") +def pg_container(): + name = f"trf-test-pg-remission-{uuid.uuid4().hex[:8]}" + subprocess.run( + [ + "docker", "run", "--rm", "-d", + "--name", name, + "-e", "POSTGRES_PASSWORD=postgres", + "-e", "POSTGRES_DB=target_revenue_test", + "-p", "127.0.0.1::5432", + "postgres:16-alpine", + ], + check=True, capture_output=True, + ) + try: + port_out = subprocess.run( + ["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True + ).stdout.strip() + host_port = port_out.split(":")[-1] + dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres" + + for _ in range(60): + try: + with psycopg.connect(dsn, connect_timeout=1): + break + except psycopg.OperationalError: + time.sleep(0.5) + else: + raise RuntimeError("postgres container did not become ready in time") + + with psycopg.connect(dsn) as conn: + for migration in MIGRATIONS: + conn.execute(migration.read_text(encoding="utf-8")) + conn.commit() + token = "test-token-acme" + conn.execute( + "INSERT INTO licensors (token, licensor_id, credential_label, rights) " + "VALUES (%s, %s, %s, %s)", + (token, "acme-corp", "admin-acme", "admin"), + ) + conn.commit() + + app_dsn = ( + f"host=127.0.0.1 port={host_port} dbname=target_revenue_test " + f"user=trf_app password=changeme-in-deployment" + ) + yield {"admin_dsn": dsn, "app_dsn": app_dsn, "token": token} + finally: + subprocess.run(["docker", "stop", name], capture_output=True) + + +@pytest.fixture() +def client(pg_container, monkeypatch): + monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"]) + monkeypatch.setenv("TRF_SIGNING_KEY_HEX", "11" * 32) + from target_revenue.service import app as app_module + + if hasattr(app_module.app.state, "pool"): + app_module.app.state.pool.close() + del app_module.app.state.pool + if hasattr(app_module.app.state, "signing_key"): + del app_module.app.state.signing_key + with TestClient(app_module.app) as c: + yield c + + +def auth_headers(token: str): + return {"Authorization": f"Bearer {token}"} + + +def test_apply_remission_writes_entry_and_is_idempotent(client, pg_container): + from target_revenue import fold + + manifest = golden_manifest() + manifest["phase"]["id"] = "trsl:phase:remission-test-" + uuid.uuid4().hex[:8] + # Multi-year longstop so wall-clock drift between the two apply calls + # stays under MIN_REMISSION_AMOUNT (idempotency floor). Backdate t0 so + # R(now) is still a material fraction of T0. + now = datetime.now(timezone.utc) + manifest["phase"]["longstop_at"] = (now + timedelta(days=365 * 5)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + r = client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])) + assert r.status_code == 201, r.text + phase_id = manifest["phase"]["id"] + + with psycopg.connect(pg_container["admin_dsn"]) as conn: + conn.execute( + "UPDATE phase_manifests SET registered_at = %s WHERE phase_id = %s", + (now - timedelta(days=365), phase_id), + ) + conn.commit() + + first = client.post( + f"/phases/{phase_id}/remission", headers=auth_headers(pg_container["token"]) + ) + assert first.status_code == 201, first.text + body = first.json() + assert body["status"] == "appended" + entry = body["entry"] + assert entry["type"] == "remission-credit" + assert entry["amount"] > 0 + assert entry["extension"]["id"] == "trsl:policy:linear-longstop-v0" + + second = client.post( + f"/phases/{phase_id}/remission", headers=auth_headers(pg_container["token"]) + ) + assert second.status_code == 201, second.text + assert second.json()["status"] == "up_to_date" + assert second.json()["entry"] is None + + entries = client.get(f"/phases/{phase_id}/ledger").json() + remissions = [e for e in entries if e["type"] == "remission-credit"] + assert len(remissions) == 1 + + result = fold.fold_outstanding_target(manifest["phase"]["initial_target"]["amount"], entries) + assert result.remission_credit == remissions[0]["amount"] + + # Attribution: submitted by system:policy-engine, not the human token. + with psycopg.connect(pg_container["admin_dsn"]) as conn: + row = conn.execute( + """ + SELECT li.credential_label + FROM ledger_entries le + JOIN licensors li ON li.token = le.submitted_by_token + WHERE le.phase_id = %s + """, + (phase_id,), + ).fetchone() + assert row[0] == "system:policy-engine" + + +def test_metrics_include_remission_forecasts(client, pg_container): + manifest = golden_manifest() + manifest["phase"]["id"] = "trsl:phase:remission-metrics-" + uuid.uuid4().hex[:8] + client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])).raise_for_status() + phase_id = manifest["phase"]["id"] + + metrics = client.get(f"/phases/{phase_id}/metrics").json() + assert metrics["facts"]["activated_at"] is not None + assert "remission_if_applied_now" in metrics["forecasts"] + assert "next_scheduled_remission_at" in metrics["forecasts"] diff --git a/workplans/TREV-WP-0013-remission-credit-automation.md b/workplans/TREV-WP-0013-remission-credit-automation.md index 50efd36..e58779a 100644 --- a/workplans/TREV-WP-0013-remission-credit-automation.md +++ b/workplans/TREV-WP-0013-remission-credit-automation.md @@ -4,11 +4,11 @@ type: workplan title: "Remission Credit automation (degeneration policy execution)" domain: infotech repo: target-revenue -status: active +status: finished owner: claude topic_slug: infotech created: "2026-07-30" -updated: "2026-07-30" +updated: "2026-08-05" state_hub_workstream_id: "4a423219-2a3e-413c-b0dc-f8542c2afba1" --- @@ -21,22 +21,20 @@ Spun out of `workplans/TREV-WP-0012-phase-provenance-and-policy-modeling.md` generate `remission-credit` Target Ledger entries over time when Development Credit progress is insufficient (FR-6, `specs/ProductRequirementsDocument.md`). **Nothing in this codebase -currently computes or writes these entries.** `fold.py` can consume them -if they exist; nothing produces them. This gap predates the Control Plane -UI work — it was never in scope for WP-0006 (Trust Service) or WP-0009 -(Control Plane), and surfaced now only because reviewing the UI's +previously computed or wrote these entries.** `fold.py` can consume them +if they exist; this workplan produces them. This gap predated the Control +Plane UI work — it was never in scope for WP-0006 (Trust Service) or +WP-0009 (Control Plane), and surfaced when reviewing the UI's Phase-registration flow prompted a fuller look at what a Phase's lifecycle actually requires end to end. Blocked on `workplans/TREV-WP-0012-phase-provenance-and-policy-modeling.md` -T03's decision (how a policy id maps to its spec file) — the -implementation here should be checkable against that spec file, per the -user's explicit requirement that policy code and policy specification stay -verifiably in sync. +T03's decision (how a policy id maps to its spec file) — **unblocked** +when WP-0012 finished and WP-0015 landed `specs/policies/linear-longstop-v0.md`. ```task id: TREV-WP-0013-T01 -status: wait +status: done priority: high state_hub_task_id: "d6ab7448-c009-4398-b616-560e8085cf1a" ``` @@ -52,9 +50,29 @@ against whatever `trsl:policy:linear-longstop-v0`'s spec file (once WP-0012-T03 lands) says, rather than re-deriving the formula from Q7 prose alone. +**Result (2026-08-05):** Design recorded in +`src/target_revenue/remission.py` module docstring and +`specs/policies/linear-longstop-v0.md` Status section: + +1. **t0** = Trust Service `phase_manifests.registered_at`. No new + manifest `activated_at` field for Stage 0 — a Phase is not active + until registered. Pure callers pass `t0` explicitly. +2. **Cadence / idempotency**: cumulative delta model, not period-keyed + rows. Each run remits `max(0, R(as_of) − Σ policy remission-credit)`. + Re-run at the same `as_of` is a no-op; missed schedules catch up + without double-counting. Dust floor `MIN_REMISSION_AMOUNT = 0.01`. + Scheduled convention: monthly UTC (1st 00:00) or longstop if sooner. +3. **Actor**: dedicated Licensor credential labeled + `system:policy-engine` (rights operator), auto-issued on first use. + Never a null `submitted_by_token`; never a human credential for + policy-driven rows. Control Plane audit still records which human + *triggered* an on-demand run. +4. Formula checked against `specs/policies/linear-longstop-v0.md`, not + re-derived from Q7 prose alone. + ```task id: TREV-WP-0013-T02 -status: wait +status: done priority: high state_hub_task_id: "078da2e7-594b-4bd3-85b6-881a808f1ca2" ``` @@ -67,9 +85,20 @@ job, an on-demand Control Plane action, or both. Decide which actor naturally owns a policy-driven entry) and record that decision explicitly rather than leaving `submitted_by_token` implicitly null. +**Result:** `src/target_revenue/remission.py` — pure +`cumulative_remission` / `plan_remission` / `build_remission_entry_input` +plus hosted `apply_remission_for_phase` / +`apply_remission_for_all_phases`. Trust Service routes: +`POST /phases/{id}/remission` (on-demand, Operator+), +`POST /remission/run` (batch for cron). Control Plane: +`control_plane.apply_policy_remission` + form on phase detail. +Tests: `tests/test_remission.py` (pure), `tests/test_remission_hosting.py` +(Docker Postgres — append, idempotency, `system:policy-engine` +attribution, metrics forecasts). + ```task id: TREV-WP-0013-T03 -status: wait +status: done priority: medium state_hub_task_id: "fe12ee80-2868-4857-83ca-04bd7223e270" ``` @@ -79,3 +108,12 @@ table already renders `remission-credit` rows generically once they exist; verify that holds, and add a metrics-level explanation (e.g. next scheduled remission date/amount) if `metrics.py` doesn't already forecast one. + +**Result:** `metrics.compute_metrics` gains optional `activated_at` and +forecasts `remission_if_applied_now`, `next_scheduled_remission_at`, +`next_scheduled_remission_amount` (labeled **forecast**, never facts). +Hosted metrics and Control Plane phase detail pass +`registered_at` as t0. `phase_detail.html` shows the forecast table, +activation/longstop facts, and an Operator+ "Apply policy remission now" +button. Existing ledger table continues to render `remission-credit` +rows generically. diff --git a/workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md b/workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md index 9dd0389..a0b899f 100644 --- a/workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md +++ b/workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md @@ -4,11 +4,11 @@ type: workplan title: "Control Plane UI: Extension Registry, Breach Records, Conversion Attestation" domain: infotech repo: target-revenue -status: active +status: finished owner: claude topic_slug: infotech created: "2026-07-30" -updated: "2026-07-30" +updated: "2026-08-05" state_hub_workstream_id: "9642c16e-5dd2-4bd6-881b-0eb04a8cf168" --- @@ -25,7 +25,7 @@ did for Phases and the Ledger. Not blocked on WP-0012; can start any time. ```task id: TREV-WP-0014-T01 -status: todo +status: done priority: medium state_hub_task_id: "8502c68b-4638-4242-9e4e-f9938dc62c9a" ``` @@ -40,9 +40,21 @@ whatever rights tier canonicalization review actually requires (check `specs/TargetRevenueControlPlaneConcept.md` §2's rights table — this may need its own entry there if it's missing). +**Result (2026-08-05):** Rights decided and recorded in concept §2 and +`control_plane.py` module comments: + +- **Register extension: Operator+** (same tier as Phase registration). +- **Promote to canonical: Admin only** (governance, never automated). + +UI: `GET/POST /extensions`, `POST /extensions/promote`, template +`extensions.html`, nav link for all signed-in users (read). Wrappers +`control_plane.register_extension` / +`control_plane.promote_extension_canonical` with audit log. +`registry.list_extensions` added for the listing. + ```task id: TREV-WP-0014-T02 -status: todo +status: done priority: medium state_hub_task_id: "8ba38f04-8612-426d-b11f-0ebdd8cea2f6" ``` @@ -53,9 +65,15 @@ respecting the anonymized-by-default / named-only-with-opt-in rule (FR-10, License V1C1 §7.4), and a read view on `phase_detail.html` listing a Phase's published records. +**Result:** Operator+ form on `phase_detail.html` posts to +`POST /phases/{id}/breach` via `control_plane.publish_breach_event`. +Anonymized default; named path requires `named_disclosure_authorized` +affirmation. Published records listed on the same page (read for all +signed-in tiers). + ```task id: TREV-WP-0014-T03 -status: todo +status: done priority: low state_hub_task_id: "b8c46793-c459-4890-90e6-cc112f8c8cb9" ``` @@ -67,9 +85,14 @@ Future License and the supporting ledger checkpoint, rather than requiring a caller to hit the Trust Service's `GET /phases/{id}/attestation` directly to see it. +**Result:** `phase_detail` loads `control_plane.get_or_publish_attestation` +(idempotent publish on first observation of Outstanding Target = 0) and +renders Future License, final credits, ledger checkpoint, and signature +when present; empty state when not converted. + ```task id: TREV-WP-0014-T04 -status: todo +status: done priority: low state_hub_task_id: "919e48cb-92f5-49cd-aa16-19889efd258b" ``` @@ -77,3 +100,9 @@ state_hub_task_id: "919e48cb-92f5-49cd-aa16-19889efd258b" **Tests and docs**: Docker-gated `TestClient` tests for all three (same pattern as `tests/test_control_plane_app.py`), workplan Result sections, `README.md` row update. + +**Result:** Four new tests in `tests/test_control_plane_app.py` +(extension register/promote rights, anonymized breach, named breach CUA +gate, attestation after conversion). Concept §2 rights table updated; +README active-work row for WP-0014 marked finished; PhaseLifecycleUseCases +use case 6 open question closed by reference to the concept update.