Go-live T05 + WP-0013/0014: first Phase and Control Plane completion

Accept WP-0008-T05 for trsl:phase:info-tech-canon-service-surface
(history/260805-T05-GoLive-info-tech-canon.md). Finish WP-0013 remission
automation and WP-0014 extension/breach/attestation Control Plane UI.
Update SCOPE, README, and pilot-candidate notes for pilot Stage 1.
This commit is contained in:
tegwick 2026-08-05 16:00:06 +02:00
parent f56d82f09a
commit 3064c0fe0c
18 changed files with 1676 additions and 72 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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(),

View file

@ -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:

View file

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

View file

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

View file

@ -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+) --------------------------------------------------

View file

@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Extensions — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Monetization Extension Registry</span>
</wn-page-header>
<p style="color:#888;font-size:0.9rem;">
Rights: <strong>Operator+</strong> may register; <strong>Admin</strong> may
promote to <code>canonical</code> (governance action, never automated).
See <code>specs/TargetRevenueControlPlaneConcept.md</code> §2.
</p>
{% if extensions %}
<table class="wn-plain">
<thead>
<tr>
<th>id</th>
<th>version</th>
<th>status</th>
<th>licensor</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ext in extensions %}
<tr>
<td><code>{{ ext.extension_id }}</code></td>
<td>{{ ext.version }}</td>
<td><wn-tag>{{ ext.status }}</wn-tag></td>
<td>{{ ext.licensor_id }}</td>
<td>
{% if session_rights == "admin" and ext.status == "registered" %}
<form method="post" action="/extensions/promote" style="display:inline" class="wn-form">
<input type="hidden" name="extension_id" value="{{ ext.extension_id }}">
<input type="hidden" name="version" value="{{ ext.version }}">
<wn-button type="submit" variant="secondary">Promote to canonical</wn-button>
</form>
{% endif %}
</td>
</tr>
<tr>
<td colspan="5" style="color:#666;font-size:0.85rem;padding-top:0;">
{{ ext.contract.value.description }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<wn-empty-state>No extensions registered yet.</wn-empty-state>
{% endif %}
{% if session_rights in ("operator", "admin") %}
<h3>Register a new extension</h3>
<form class="wn-form" method="post" action="/extensions">
<wn-field-row label="Extension id (trsl:extension:...)">
<wn-input name="extension_id" required></wn-input>
</wn-field-row>
<wn-field-row label="Version (e.g. 1.0)">
<wn-input name="version" required></wn-input>
</wn-field-row>
<wn-field-row label="Value description">
<wn-input name="value_description" required></wn-input>
</wn-field-row>
<wn-field-row label="Pricing method">
<wn-input name="pricing_method" required></wn-input>
</wn-field-row>
<wn-field-row label="Allocation rule">
<wn-input name="allocation_rule" required></wn-input>
</wn-field-row>
<wn-field-row label="Default rate (01, optional)">
<wn-input type="number" step="0.01" min="0" max="1" name="default_rate"></wn-input>
</wn-field-row>
<wn-field-row label="Recognition event">
<wn-input name="recognition_event" value="payment-settled" required></wn-input>
</wn-field-row>
<wn-field-row label="Reversal rule">
<wn-input name="reversal_rule" required></wn-input>
</wn-field-row>
<wn-field-row label="Evidence requirement">
<wn-input name="evidence_requirement" required></wn-input>
</wn-field-row>
<wn-button type="submit" variant="primary">Register extension</wn-button>
</form>
{% endif %}
{% endblock %}

View file

@ -31,8 +31,47 @@
&middot; <a href="/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a>
</td>
</tr>
<tr><td>Longstop</td><td>{{ metrics.facts.longstop_at or "—" }}</td></tr>
<tr><td>Activated (t0)</td><td>{{ metrics.facts.activated_at or "—" }}</td></tr>
</table>
<h3>Remission forecast <wn-tag>forecast</wn-tag></h3>
<table class="wn-plain">
<tr>
<td>If applied now</td>
<td>
{% if metrics.forecasts.remission_if_applied_now is not none %}
{{ metrics.forecasts.remission_if_applied_now }} {{ metrics.facts.initial_target_currency }}
{% else %}
{% endif %}
</td>
</tr>
<tr>
<td>Next scheduled (monthly UTC / longstop)</td>
<td>{{ metrics.forecasts.next_scheduled_remission_at or "—" }}</td>
</tr>
<tr>
<td>Amount at next schedule</td>
<td>
{% if metrics.forecasts.next_scheduled_remission_amount is not none %}
{{ metrics.forecasts.next_scheduled_remission_amount }} {{ metrics.facts.initial_target_currency }}
{% else %}
{% endif %}
</td>
</tr>
</table>
{% if session_rights in ("operator", "admin") %}
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/remission" style="margin-bottom:1.5rem;">
<wn-button type="submit" variant="secondary">Apply policy remission now</wn-button>
<p style="color:#888;font-size:0.85rem;margin-top:0.5rem;">
Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
(idempotent — no double-remit if already current).
</p>
</form>
{% endif %}
<h3>Ledger ({{ ledger | length }} entries)</h3>
{% if ledger %}
<table class="wn-plain">
@ -78,4 +117,98 @@
</wn-button>
</form>
{% endif %}
<h3>Conversion Attestation</h3>
{% if attestation %}
<table class="wn-plain">
<tr><td>Phase</td><td>{{ attestation.phase }}</td></tr>
<tr><td>Milestone release</td><td>{{ attestation.milestone_release }}</td></tr>
<tr><td>Converted at</td><td>{{ attestation.conversion_timestamp }}</td></tr>
<tr><td>Future License</td><td><strong>{{ attestation.future_license }}</strong></td></tr>
<tr><td>Final development credit</td><td>{{ attestation.final_development_credit }}</td></tr>
<tr><td>Final remission credit</td><td>{{ attestation.final_remission_credit }}</td></tr>
<tr><td>Outstanding at conversion</td><td>{{ attestation.final_outstanding_target }}</td></tr>
<tr><td>Ledger checkpoint</td><td><code>{{ attestation.ledger_checkpoint }}</code></td></tr>
<tr><td>Signature</td><td><code style="font-size:0.75rem;word-break:break-all;">{{ attestation.signature }}</code></td></tr>
</table>
{% elif metrics.facts.is_converted %}
<p>Phase is converted; attestation will publish on next observation.</p>
{% else %}
<wn-empty-state>Not converted — no attestation yet.</wn-empty-state>
{% endif %}
<h3>Breach / Compliance Records ({{ breaches | length }})</h3>
{% if breaches %}
<table class="wn-plain">
<thead>
<tr>
<th>id</th>
<th>case</th>
<th>type</th>
<th>category</th>
<th>event_at</th>
<th>named?</th>
</tr>
</thead>
<tbody>
{% for b in breaches %}
<tr>
<td>{{ b.id }}</td>
<td>{{ b.case_id }}</td>
<td>{{ b.event_type }}</td>
<td>{{ b.category }}</td>
<td>{{ b.event_at }}</td>
<td>
{% if b.anonymized %}
anonymized
{% else %}
{{ b.named_entitlement_holder }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<wn-empty-state>No breach/compliance records published for this Phase.</wn-empty-state>
{% endif %}
{% if session_rights in ("operator", "admin") %}
<h3>Publish a breach/compliance event</h3>
<p style="color:#888;font-size:0.85rem;">
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.
</p>
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/breach">
<wn-field-row label="Record id (unique)">
<wn-input name="record_id" required></wn-input>
</wn-field-row>
<wn-field-row label="Case id">
<wn-input name="case_id" required></wn-input>
</wn-field-row>
<wn-field-row label="Event type (alleged|cured|determined|terminated)">
<wn-input name="event_type" value="alleged" required></wn-input>
</wn-field-row>
<wn-field-row label="Category">
<wn-input name="category" required></wn-input>
</wn-field-row>
<wn-field-row label="Event at (ISO 8601)">
<wn-input name="event_at" required></wn-input>
</wn-field-row>
<wn-field-row label="Evidence reference (optional)">
<wn-input name="evidence_reference"></wn-input>
</wn-field-row>
<wn-field-row label="Anonymized (true/false)">
<wn-input name="anonymized" value="true" required></wn-input>
</wn-field-row>
<wn-field-row label="Named entitlement holder (only if anonymized=false)">
<wn-input name="named_entitlement_holder"></wn-input>
</wn-field-row>
<wn-field-row label="Named disclosure authorized under CUA (true if named)">
<wn-input name="named_disclosure_authorized" value="false"></wn-input>
</wn-field-row>
<wn-button type="submit" variant="secondary">Publish breach record</wn-button>
</form>
{% endif %}
{% endblock %}

View file

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

View file

@ -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):

158
tests/test_remission.py Normal file
View file

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

View file

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

View file

@ -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.

View file

@ -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.