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
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue