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 %} + + Monetization Extension Registry + + +

+ Rights: Operator+ may register; Admin may + promote to canonical (governance action, never automated). + See specs/TargetRevenueControlPlaneConcept.md §2. +

+ +{% if extensions %} + + + + + + + + + + + + {% for ext in extensions %} + + + + + + + + + + + {% endfor %} + +
idversionstatuslicensor
{{ ext.extension_id }}{{ ext.version }}{{ ext.status }}{{ ext.licensor_id }} + {% if session_rights == "admin" and ext.status == "registered" %} +
+ + + Promote to canonical +
+ {% endif %} +
+ {{ ext.contract.value.description }} +
+{% else %} +No extensions registered yet. +{% endif %} + +{% if session_rights in ("operator", "admin") %} +

Register a new extension

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + Register extension +
+{% endif %} +{% endblock %} diff --git a/src/target_revenue/service/control_plane_templates/phase_detail.html b/src/target_revenue/service/control_plane_templates/phase_detail.html index becbc53..0484e33 100644 --- a/src/target_revenue/service/control_plane_templates/phase_detail.html +++ b/src/target_revenue/service/control_plane_templates/phase_detail.html @@ -31,8 +31,47 @@ · view spec + Longstop{{ metrics.facts.longstop_at or "—" }} + Activated (t0){{ metrics.facts.activated_at or "—" }} +

Remission forecast forecast

+ + + + + + + + + + + + + +
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 %} +
+{% if session_rights in ("operator", "admin") %} +
+ Apply policy remission now +

+ Writes a remission-credit delta under system:policy-engine + (idempotent — no double-remit if already current). +

+
+{% endif %} +

Ledger ({{ ledger | length }} entries)

{% if ledger %} @@ -78,4 +117,98 @@ {% endif %} + +

Conversion Attestation

+{% if attestation %} +
+ + + + + + + + + +
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 }}
+{% elif metrics.facts.is_converted %} +

Phase is converted; attestation will publish on next observation.

+{% else %} +Not converted — no attestation yet. +{% endif %} + +

Breach / Compliance Records ({{ breaches | length }})

+{% if breaches %} + + + + + + + + + + + + + {% for b in breaches %} + + + + + + + + + {% endfor %} + +
idcasetypecategoryevent_atnamed?
{{ b.id }}{{ b.case_id }}{{ b.event_type }}{{ b.category }}{{ b.event_at }} + {% if b.anonymized %} + anonymized + {% else %} + {{ b.named_entitlement_holder }} + {% endif %} +
+{% else %} +No breach/compliance records published for this Phase. +{% endif %} + +{% if session_rights in ("operator", "admin") %} +

Publish a breach/compliance event

+

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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + Publish breach record +
+{% 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.