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:
parent
f56d82f09a
commit
3064c0fe0c
18 changed files with 1676 additions and 72 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
367
src/target_revenue/remission.py
Normal file
367
src/target_revenue/remission.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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+) --------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (0–1, 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 %}
|
||||
|
|
@ -31,8 +31,47 @@
|
|||
· <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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
158
tests/test_remission.py
Normal 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
|
||||
178
tests/test_remission_hosting.py
Normal file
178
tests/test_remission_hosting.py
Normal 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"]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue