diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61c14da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Target Revenue Trust Service + Control Plane (WP-0011). +# Image: forgejo.coulomb.social/coulomb/target-revenue +FROM python:3.12-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HOME=/home/app + +RUN useradd --create-home --home-dir /home/app --uid 10001 app + +WORKDIR /src +COPY pyproject.toml README.md LICENSE ./ +COPY src/target_revenue ./src/target_revenue +COPY migrations ./migrations +COPY schemas ./schemas +COPY specs/policies ./specs/policies +COPY specs/profiles ./specs/profiles + +RUN pip install --no-cache-dir --index-url https://pypi.org/simple ".[service]" \ + && chown -R app:app /src + +COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY scripts/apply_migrations.py /usr/local/bin/apply_migrations.py +COPY scripts/bootstrap_binky.py /usr/local/bin/bootstrap_binky.py +RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ + /usr/local/bin/apply_migrations.py \ + /usr/local/bin/bootstrap_binky.py + +USER app +EXPOSE 8000 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 774b2c4..3fd714c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule** | [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout — **finished**, all 5 tasks done. **T05 accepted 2026-08-05**: first real Phase is `trsl:phase:info-tech-canon-service-surface` (`history/260805-T05-GoLive-info-tech-canon.md`). Org-wide TRSL LICENSE adoption (2026-07-30) was a prior, separate act | | [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — **finished**, all 4 tasks done. **T04 (interactive UI, `src/target_revenue/service/control_plane_app.py`) built on vendored `whynot-design` web components** rather than from scratch, per an explicit feasibility check | | [TREV-WP-0010](workplans/TREV-WP-0010-development-effort-calculator.md) | Development Effort Calculator — **finished**, all 3 tasks done. Applied to the three real pilot candidates (`history/260730-EffortCalculator-CandidateApplication.md`) — every calculator-derived Initial Target came out materially lower than the earlier hand-picked placeholders, two of three carrying explicit warnings recommending manual review | -| [TREV-WP-0011](workplans/TREV-WP-0011-railiance-reef-deployment.md) | Deploy Trust Service + Control Plane to the Railiance reef, fronted by `revenue.coulomb.social` — active; T01 (deployment-pattern decision, human gate) next. Deployment alone does not authorize a real Phase go-live — that remains WP-0008-T05 | +| [TREV-WP-0011](workplans/TREV-WP-0011-railiance-reef-deployment.md) | Deploy Trust Service + Control Plane to Railiance (`revenue.coulomb.social`) — **active**; T01–T04 done (Dockerfile, `k8s/railiance/`, ArgoCD Application). **T05** remains: operator DNS/OpenBao/image-push/sync + pilot data migration. Guide: [`docs/deployment.md`](docs/deployment.md) | | [TREV-WP-0012](workplans/TREV-WP-0012-phase-provenance-and-policy-modeling.md) | Phase provenance, ledger reference, and degeneration-policy modeling — **finished**, all 5 tasks done. Decisions (T02–T04) synthesized into [`specs/PhaseProvenanceSpecAddendum.md`](specs/PhaseProvenanceSpecAddendum.md) (T05) — **not yet accepted for implementation**; that's the document to discuss before any schema/UI work is filed as its own workplan | | [TREV-WP-0013](workplans/TREV-WP-0013-remission-credit-automation.md) | Remission Credit automation (degeneration policy execution) — **finished**, all 3 tasks done. Pure `plan_remission` + hosted apply with `system:policy-engine` actor; monthly UTC schedule; Control Plane forecast + apply button | | [TREV-WP-0014](workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md) | Control Plane UI: Extension Registry, Breach Records, Conversion Attestation — **finished**, all 4 tasks done. Operator+ register extension / Admin canonicalize; breach form with anonymized-by-default; attestation on phase detail | diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 105e5e0..22a51f3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,7 +15,7 @@ | workplan | TREV-WP-0005 | finished | — | workplans/TREV-WP-0005-enforcement-network-research.md | | workplan | TREV-WP-0006 | finished | — | workplans/TREV-WP-0006-trust-service-implementation.md | | workplan | TREV-WP-0007 | finished | — | workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md | -| workplan | TREV-WP-0008 | active | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | +| workplan | TREV-WP-0008 | finished | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | | workplan | TREV-WP-0009 | finished | — | workplans/TREV-WP-0009-target-revenue-control-plane.md | | workplan | TREV-WP-0010 | finished | — | workplans/TREV-WP-0010-development-effort-calculator.md | | workplan | TREV-WP-0011 | active | — | workplans/TREV-WP-0011-railiance-reef-deployment.md | @@ -78,7 +78,7 @@ | task | TREV-WP-0008-T02 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | | task | TREV-WP-0008-T03 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | | task | TREV-WP-0008-T04 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | -| task | TREV-WP-0008-T05 | todo | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | +| task | TREV-WP-0008-T05 | done | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | | task | TREV-WP-0009-T01 | done | — | workplans/TREV-WP-0009-target-revenue-control-plane.md | | task | TREV-WP-0009-T02 | done | — | workplans/TREV-WP-0009-target-revenue-control-plane.md | | task | TREV-WP-0009-T03 | done | — | workplans/TREV-WP-0009-target-revenue-control-plane.md | diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..b822eea --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,69 @@ +# Deploying Target Revenue Trust Service + Control Plane + +**Workplan:** `workplans/TREV-WP-0011-railiance-reef-deployment.md` +**Public host (target):** `https://revenue.coulomb.social` +**Control Plane UI:** `https://revenue.coulomb.social/ui/` +**Trust Service API:** `https://revenue.coulomb.social/` (e.g. `/phases/...`, `/public-key`) + +This deployment does not by itself authorize new Phases beyond what +WP-0008-T05 already recorded. The first pilot Phase is +`trsl:phase:info-tech-canon-service-surface` +(`history/260805-T05-GoLive-info-tech-canon.md`). + +--- + +## T01 decision (2026-08-05) + +| Bullet | Decision | +| --- | --- | +| Manifest home | In-repo `k8s/railiance/` (issue-core pattern); ArgoCD Application in `railiance-platform` | +| Process topology | **Single process**: `combined.py` mounts Control Plane at `/ui`, Trust Service at `/` | +| Database | Dedicated CNPG Cluster `target-revenue-pg` in namespace `target-revenue` (not state-hub DB) | +| Secrets | OpenBao + ExternalSecret → `target-revenue-runtime`; CNPG role secrets operator-seeded | +| Subdomain / TLS | `revenue.coulomb.social`, `cert-manager.io/cluster-issuer: letsencrypt-prod` (same as `forgejo.coulomb.social`) | +| Founding credential | Idempotent Job `target-revenue-bootstrap-binky` (`scripts/bootstrap_binky.py`) | + +--- + +## Operator checklist (first bring-up) + +1. **DNS:** point `revenue.coulomb.social` at the Railiance Traefik/ingress (same path as other `*.coulomb.social` hosts on railiance01). +2. **OpenBao:** create KV path `workloads/target-revenue/runtime-secrets` with: + - `TRF_SIGNING_KEY_HEX` (64 hex chars) + - `TRF_CONTROL_PLANE_SECRET_KEY` + - `TRF_DATABASE_URL` (trf_app DSN to `target-revenue-pg-rw`) + - `TRF_MIGRATE_DATABASE_URL` (trf_owner DSN for migrate/bootstrap Jobs) +3. **CNPG bootstrap secrets** in namespace `target-revenue` (before Cluster reconciles): + - `target-revenue-pg-credentials` — keys as required by CNPG for owner `trf_owner` + - `target-revenue-trf-app-credentials` — password for managed role `trf_app` +4. **Image:** build and push + `forgejo.coulomb.social/coulomb/target-revenue:0.1.0` + (see Dockerfile; bump tag in Deployment + Jobs when releasing). +5. **ArgoCD:** apply + `railiance-platform/argocd/applications/target-revenue.application.yaml` + (once `main` contains `k8s/railiance/`). +6. **After migrate + bootstrap Jobs succeed:** pull founding token from bootstrap Job logs (`TOKEN=...`), store offline; sign in at `/ui/login`. +7. **Migrate pilot Phase data** from the local go-live instance (`trf-pilot-pg` / `:8765`) if that instance still holds the authoritative ledger — export Manifest + ledger JSON and re-register/append on the public instance (or promote the pilot DB dump deliberately). Do not leave two competing ledgers. + +--- + +## Local pilot instance (already used for T05) + +| Item | Value | +| --- | --- | +| API | `http://127.0.0.1:8765` | +| Postgres container | `trf-pilot-pg` / volume `trf-pilot-pgdata` / port `55432` | +| Token file | `~/.config/target-revenue/pilot-binky.token` | + +```bash +python scripts/trf_onboard.py status \ + --url http://127.0.0.1:8765 \ + --phase-id trsl:phase:info-tech-canon-service-surface +``` + +--- + +## Health + +- Combined / standalone: `GET /healthz` → `{"status":"ok"}` (no DB dependency) +- Control Plane when mounted: `GET /ui/healthz` diff --git a/k8s/railiance/bootstrap-job.yaml b/k8s/railiance/bootstrap-job.yaml new file mode 100644 index 0000000..73b91f2 --- /dev/null +++ b/k8s/railiance/bootstrap-job.yaml @@ -0,0 +1,54 @@ +# Idempotent founding binky admin credential (WP-0011-T01/T05). +# Safe to re-run: no-op when a binky credential already exists. +# Capture TOKEN= from job logs once on first bootstrap; store in a password +# manager — never commit. +apiVersion: batch/v1 +kind: Job +metadata: + name: target-revenue-bootstrap-binky + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: bootstrap + annotations: + argocd.argoproj.io/sync-wave: "2" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + ttlSecondsAfterFinished: 86400 + backoffLimit: 2 + template: + metadata: + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: bootstrap + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + containers: + - name: bootstrap + image: forgejo.coulomb.social/coulomb/target-revenue:0.1.0 + imagePullPolicy: IfNotPresent + env: + - name: TRF_BOOTSTRAP_BINKY + value: "1" + - name: TRF_DATABASE_URL + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_MIGRATE_DATABASE_URL + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/k8s/railiance/cnpg-cluster.yaml b/k8s/railiance/cnpg-cluster.yaml new file mode 100644 index 0000000..e153794 --- /dev/null +++ b/k8s/railiance/cnpg-cluster.yaml @@ -0,0 +1,36 @@ +# Dedicated CNPG Cluster for Target Revenue Trust Service pilot data (WP-0011). +# Co-located in the target-revenue workload namespace so one ArgoCD Application +# can own the full stack (issue-core pattern + database). +# +# Pre-req secrets in namespace target-revenue (OpenBao ExternalSecret or +# operator-seeded): target-revenue-pg-credentials, target-revenue-trf-app-credentials. +# See docs/deployment.md. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: target-revenue-pg + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue-pg + app.kubernetes.io/part-of: target-revenue + app.kubernetes.io/component: database + annotations: + argocd.argoproj.io/sync-wave: "-2" +spec: + instances: 1 + imageName: ghcr.io/cloudnative-pg/postgresql:16 + storage: + size: 5Gi + bootstrap: + initdb: + database: target_revenue + owner: trf_owner + secret: + name: target-revenue-pg-credentials + managed: + roles: + - name: trf_app + ensure: present + login: true + passwordSecret: + name: target-revenue-trf-app-credentials diff --git a/k8s/railiance/deployment.yaml b/k8s/railiance/deployment.yaml new file mode 100644 index 0000000..d6796c3 --- /dev/null +++ b/k8s/railiance/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: target-revenue + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + annotations: + argocd.argoproj.io/sync-wave: "3" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: target-revenue + template: + metadata: + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + containers: + - name: target-revenue + image: forgejo.coulomb.social/coulomb/target-revenue:0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8000 + env: + - name: TRF_DATABASE_URL + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_DATABASE_URL + - name: TRF_SIGNING_KEY_HEX + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_SIGNING_KEY_HEX + - name: TRF_CONTROL_PLANE_SECRET_KEY + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_CONTROL_PLANE_SECRET_KEY + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/k8s/railiance/externalsecret.yaml b/k8s/railiance/externalsecret.yaml new file mode 100644 index 0000000..f471265 --- /dev/null +++ b/k8s/railiance/externalsecret.yaml @@ -0,0 +1,46 @@ +# Runtime secrets for Trust Service + Control Plane (WP-0011). +# +# OpenBao path (platform KV v2): workloads/target-revenue/runtime-secrets +# Fields: +# TRF_SIGNING_KEY_HEX — 64 hex chars Ed25519 seed (32 bytes) +# TRF_CONTROL_PLANE_SECRET_KEY — session cookie signing secret +# TRF_DATABASE_URL — psycopg DSN for trf_app role +# TRF_MIGRATE_DATABASE_URL — DSN for trf_owner (migrate/bootstrap Jobs) +# +# Pre-reqs: ESO + ClusterSecretStore openbao (railiance-platform); +# OpenBao policy granting read of the path above. +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: target-revenue-runtime + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + refreshInterval: 1h + secretStoreRef: + name: openbao + kind: ClusterSecretStore + target: + name: target-revenue-runtime + creationPolicy: Owner + data: + - secretKey: TRF_SIGNING_KEY_HEX + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_SIGNING_KEY_HEX + - secretKey: TRF_CONTROL_PLANE_SECRET_KEY + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_CONTROL_PLANE_SECRET_KEY + - secretKey: TRF_DATABASE_URL + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_DATABASE_URL + - secretKey: TRF_MIGRATE_DATABASE_URL + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_MIGRATE_DATABASE_URL diff --git a/k8s/railiance/ingress.yaml b/k8s/railiance/ingress.yaml new file mode 100644 index 0000000..dfa95f8 --- /dev/null +++ b/k8s/railiance/ingress.yaml @@ -0,0 +1,30 @@ +# Public surface: revenue.coulomb.social (WP-0011). +# Trust Service API at / ; Control Plane UI at /ui/ (combined ASGI app). +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: target-revenue + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + argocd.argoproj.io/sync-wave: "4" +spec: + ingressClassName: traefik + rules: + - host: revenue.coulomb.social + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: target-revenue + port: + number: 80 + tls: + - hosts: + - revenue.coulomb.social + secretName: target-revenue-tls diff --git a/k8s/railiance/kustomization.yaml b/k8s/railiance/kustomization.yaml new file mode 100644 index 0000000..5de0d14 --- /dev/null +++ b/k8s/railiance/kustomization.yaml @@ -0,0 +1,15 @@ +# Target Revenue Trust Service + Control Plane on Railiance (WP-0011). +# Synced by ArgoCD Application target-revenue (path k8s/railiance). +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: target-revenue + +resources: + - cnpg-cluster.yaml + - externalsecret.yaml + - migrate-job.yaml + - bootstrap-job.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/k8s/railiance/migrate-job.yaml b/k8s/railiance/migrate-job.yaml new file mode 100644 index 0000000..7daf9b3 --- /dev/null +++ b/k8s/railiance/migrate-job.yaml @@ -0,0 +1,52 @@ +# One-shot migrations Job (WP-0011-T03). Re-run by deleting the Job or +# bumping the name suffix when a new migration ships. +apiVersion: batch/v1 +kind: Job +metadata: + name: target-revenue-migrate + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: migrate + annotations: + argocd.argoproj.io/sync-wave: "1" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + ttlSecondsAfterFinished: 86400 + backoffLimit: 3 + template: + metadata: + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: migrate + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + containers: + - name: migrate + image: forgejo.coulomb.social/coulomb/target-revenue:0.1.0 + imagePullPolicy: IfNotPresent + env: + - name: TRF_RUN_MIGRATIONS + value: "1" + - name: TRF_DATABASE_URL + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_MIGRATE_DATABASE_URL + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/k8s/railiance/service.yaml b/k8s/railiance/service.yaml new file mode 100644 index 0000000..73eb039 --- /dev/null +++ b/k8s/railiance/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: target-revenue + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: target-revenue + ports: + - name: http + port: 80 + targetPort: http diff --git a/scripts/apply_migrations.py b/scripts/apply_migrations.py new file mode 100755 index 0000000..364cc0f --- /dev/null +++ b/scripts/apply_migrations.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Apply SQL migrations in order against TRF_DATABASE_URL (WP-0011). + +Idempotent migrations (IF NOT EXISTS / OR REPLACE). Safe to re-run. +Uses the admin/bootstrap DSN — typically the CNPG owner role, not trf_app. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import psycopg + +MIGRATIONS_DIR = Path(os.environ.get("TRF_MIGRATIONS_DIR", "/src/migrations")) + + +def main() -> None: + dsn = os.environ.get("TRF_DATABASE_URL") or os.environ.get("TRF_MIGRATE_DATABASE_URL") + if not dsn: + print("TRF_DATABASE_URL or TRF_MIGRATE_DATABASE_URL is required", file=sys.stderr) + sys.exit(1) + files = sorted(MIGRATIONS_DIR.glob("*.sql")) + if not files: + print(f"no migrations in {MIGRATIONS_DIR}", file=sys.stderr) + sys.exit(1) + with psycopg.connect(dsn) as conn: + for path in files: + print(f"applying {path.name} ...") + conn.execute(path.read_text(encoding="utf-8")) + conn.commit() + print(f" ok {path.name}") + print(f"applied {len(files)} migration(s)") + + +if __name__ == "__main__": + main() diff --git a/scripts/bootstrap_binky.py b/scripts/bootstrap_binky.py new file mode 100755 index 0000000..cb91995 --- /dev/null +++ b/scripts/bootstrap_binky.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Idempotent founding `binky` admin credential bootstrap (WP-0011-T01). + +If no active binky credential exists, issues one labeled ``founding-admin`` +with admin rights and prints the token once to stdout (and optionally +writes it to TRF_BOOTSTRAP_TOKEN_FILE). Re-runs are no-ops when a +credential already exists — they print the existing label, not a new token. +""" + +from __future__ import annotations + +import os +import sys + +import psycopg + +from target_revenue import registry + + +def main() -> None: + dsn = os.environ.get("TRF_DATABASE_URL") + if not dsn: + print("TRF_DATABASE_URL is required", file=sys.stderr) + sys.exit(1) + label = os.environ.get("TRF_BOOTSTRAP_LABEL", "founding-admin") + with psycopg.connect(dsn) as conn: + row = conn.execute( + """ + SELECT token, credential_label, rights + FROM licensors + WHERE licensor_id = 'binky' AND revoked_at IS NULL + ORDER BY created_at ASC + LIMIT 1 + """ + ).fetchone() + if row is not None: + token, existing_label, rights = row + print( + f"binky already has active credential label={existing_label!r} " + f"rights={rights!r} — bootstrap no-op (token not re-printed)" + ) + conn.commit() + return + cred = registry.issue_sub_credential( + conn, + licensor_id="binky", + credential_label=label, + rights="admin", + issued_by="bootstrap_binky", + ) + conn.commit() + print(f"issued binky credential label={cred.credential_label!r}") + print(f"TOKEN={cred.token}") + out = os.environ.get("TRF_BOOTSTRAP_TOKEN_FILE") + if out: + with open(out, "w", encoding="utf-8") as f: + f.write(cred.token) + print(f"wrote token to {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..75e4410 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Container entrypoint (WP-0011): combined Trust Service + Control Plane. +set -e +# Optional: run migrations when TRF_RUN_MIGRATIONS=1 (used by migrate Job). +if [ "${TRF_RUN_MIGRATIONS:-0}" = "1" ]; then + exec python /usr/local/bin/apply_migrations.py +fi +if [ "${TRF_BOOTSTRAP_BINKY:-0}" = "1" ]; then + exec python /usr/local/bin/bootstrap_binky.py +fi +exec python -m uvicorn target_revenue.service.combined:app \ + --host 0.0.0.0 \ + --port 8000 \ + --proxy-headers \ + --forwarded-allow-ips='*' diff --git a/specs/PhaseLifecycleUseCases.md b/specs/PhaseLifecycleUseCases.md index 3e7c787..14efb53 100644 --- a/specs/PhaseLifecycleUseCases.md +++ b/specs/PhaseLifecycleUseCases.md @@ -137,12 +137,9 @@ attestation about it"). `attestation.publish_attestation` is implemented, tested, and exposed at `GET /phases/{id}/attestation` — idempotent, publish-on-first-observation, never regenerated. -**What's missing is purely presentational**: the Control Plane UI has no -view of a Phase's Attestation once one exists. A caller has to know to -hit the Trust Service endpoint directly. - -**Gap → `workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md` -T03.** +**Resolved 2026-08-05** (WP-0014-T03): `phase_detail.html` shows the +Conversion Attestation (Future License, final credits, ledger checkpoint, +signature) via idempotent `get_or_publish_attestation` on page load. --- @@ -155,22 +152,14 @@ SECURITY DEFINER, never a plain `UPDATE`) — the same pattern already used for credential revocation and proposed-entry review elsewhere in this project. -**Two things are missing, one of them a real open question, not just -UI**: +**Resolved 2026-08-05** (`workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md` +T01, all four tasks finished): -- No Control Plane UI to register an extension or review/promote one. -- **Unresolved**: `specs/TargetRevenueControlPlaneConcept.md` §2's rights - table (cited in use case 3 above) does not mention extension - registration or canonicalization at all — unlike breach records (use - case 7), which the table explicitly assigns to Operator+. Whether - registering a new extension should require any rights tier at all - (the Trust Service's own `registry.register_extension` doesn't gate it - today — any authenticated tenant can call it directly), and who should - be able to canonicalize one, needs an explicit answer before UI work - starts, not an assumption made while building the form. - -**Gap → `workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md` -T01.** +- Control Plane UI: `GET/POST /extensions`, Admin promote-to-canonical. +- Rights (now in `specs/TargetRevenueControlPlaneConcept.md` §2): + **Operator+** registers; **Admin** promotes to canonical. Trust Service + `POST /extensions` remains open to any authenticated tenant token — + Control Plane policy is layered on top. --- @@ -180,14 +169,12 @@ FR-10 and License V1C1 §7.4: a Licensor's own breach/termination determination, anonymized (Phase + category only) by default, named only with an explicit Commercial Use Agreement opt-in. `breach_record.publish_breach_event`/`get_breach_records` are implemented -and tested. **Unlike extension registration, this one's rights tier is -already decided**: `specs/TargetRevenueControlPlaneConcept.md` §2 assigns -"publish breach/compliance records" to Operator+ explicitly, alongside -Phase registration and direct ledger append. No open rights question here -— purely a missing UI. +and tested. Rights tier is Operator+ +(`specs/TargetRevenueControlPlaneConcept.md` §2). -**Gap → `workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md` -T02.** +**Resolved 2026-08-05** (WP-0014-T02): Control Plane form + list on +`phase_detail.html`; named disclosure requires explicit CUA-authorization +affirmation (records the assertion; does not verify CUA text). --- @@ -234,12 +221,12 @@ conformance suite (`tests/test_hosted_conformance.py`). | # | Use case | Status | |---|---|---| -| 1 | First Phase for a repo | Partially modeled — no repo/provenance fields (→ T02) | -| 2 | Successive Phase, same repo | Not modeled at all (→ T02) | +| 1 | First Phase for a repo | Done (WP-0015 provenance fields) | +| 2 | Successive Phase, same repo | Done (WP-0015 `base_phase_id`) | | 3 | Register / propose / review (rights model) | Done (WP-0009) | -| 4 | Remission Credit accrual | Not implemented (→ WP-0013) | -| 5 | Conversion Attestation view | Backend done, no UI (→ WP-0014-T03) | -| 6 | Extension registration/canonicalization | Backend done, no UI, rights tier undecided (→ WP-0014-T01) | -| 7 | Breach/Compliance Record | Backend done, no UI, rights tier already decided (→ WP-0014-T02) | +| 4 | Remission Credit accrual | Done (WP-0013) | +| 5 | Conversion Attestation view | Done (WP-0014-T03) | +| 6 | Extension registration/canonicalization | Done (WP-0014-T01) | +| 7 | Breach/Compliance Record | Done (WP-0014-T02) | | 8 | Second Licensor tenant onboarding | Structurally supported, no onboarding flow — deferred to WP-0008 | | 9 | Offline evidence verification | Done by design, no UI needed | diff --git a/specs/TargetRevenueControlPlaneConcept.md b/specs/TargetRevenueControlPlaneConcept.md index 8f99b74..62776de 100644 --- a/specs/TargetRevenueControlPlaneConcept.md +++ b/specs/TargetRevenueControlPlaneConcept.md @@ -45,10 +45,19 @@ T01, maintainer-accepted, as proposed): | Right | Can do | |---|---| -| Viewer | Read Phase status, metrics, ledger, attestations, breach records (all already public per FR-9/FR-10 — a Viewer arguably needs no new auth at all, since these are unauthenticated reads today) | +| Viewer | Read Phase status, metrics, ledger, attestations, breach records, extension registry (all already public per FR-9/FR-10 — a Viewer arguably needs no new auth at all, since these are unauthenticated reads today) | | Contributor | Everything Viewer can, plus: submit a *proposed* Development Credit entry for review (not directly appended) | -| Operator | Everything Contributor can, plus: directly append Development Credit/Remission Credit entries, register new Phases, publish breach/compliance records | -| Admin | Everything Operator can, plus: manage which human users hold which right within the `binky` tenant | +| Operator | Everything Contributor can, plus: directly append Development Credit/Remission Credit entries, register new Phases, register new Monetization Extensions, publish breach/compliance records, trigger policy remission | +| Admin | Everything Operator can, plus: manage which human users hold which right within the `binky` tenant, **promote a Monetization Extension from `registered` to `canonical`** | + +**Extension rights (resolved 2026-08-05, WP-0014-T01):** the gap noted in +`specs/PhaseLifecycleUseCases.md` use case 6 is closed here. Registering +an extension is Operator+ (same tier as Phase registration — the tenant is +publishing structure it will use on its ledgers). Canonicalization is +Admin only (governance action, never automated; matches +`set_extension_status` SECURITY DEFINER and credential management). The +Trust Service's own `POST /extensions` still accepts any authenticated +tenant token; these tiers are Control Plane policy layered on top. **Design tension — resolved 2026-07-30, option (a) adopted, not (b):** WP-0006's Ledger append API (`ledger.append_entry`) currently diff --git a/specs/policies/linear-longstop-v0.md b/specs/policies/linear-longstop-v0.md index 0c037f0..9bbe0df 100644 --- a/specs/policies/linear-longstop-v0.md +++ b/specs/policies/linear-longstop-v0.md @@ -26,10 +26,19 @@ cohort, not merely a Stage 0 pilot placeholder. ## Status -Not yet automated — see -`workplans/TREV-WP-0013-remission-credit-automation.md`. Nothing in this -codebase currently computes or writes these entries; this file is the -formula those entries must be checkable against once that workplan lands. +Automated as of WP-0013 (`src/target_revenue/remission.py`): + +- Pure cumulative formula: `cumulative_remission(T0, t0, tL, as_of)`. +- Hosted apply writes the delta + `max(0, R(as_of) − Σ policy remission-credit already on the ledger)`. +- **t0** = Trust Service `phase_manifests.registered_at` (no separate + `activated_at` manifest field in Stage 0). +- **Cadence**: monthly UTC (1st 00:00) for scheduled batch + (`POST /remission/run`); on-demand via + `POST /phases/{id}/remission` or Control Plane "Apply policy remission now". +- **Actor**: Licensor credential labeled `system:policy-engine` + (not a human token). +- Re-runs at the same `as_of` are no-ops (idempotent by cumulative delta). ## Superseded / next iteration diff --git a/src/target_revenue/control_plane.py b/src/target_revenue/control_plane.py index 41622a3..445602a 100644 --- a/src/target_revenue/control_plane.py +++ b/src/target_revenue/control_plane.py @@ -20,7 +20,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from . import ledger, registry +from . import attestation, breach_record, ledger, registry, remission from .registry import Licensor if TYPE_CHECKING: @@ -168,6 +168,120 @@ def append_development_credit( return stored +def apply_policy_remission( + conn: "Connection", + licensor: Licensor, + phase_id: str, + signing_key: Any, +) -> dict[str, Any] | None: + """Operator+ only: run degeneration-policy remission for one Phase + (WP-0013). Ledger attribution uses `system:policy-engine`; the audit + log records which human credential triggered the run.""" + _require_right(licensor, "operator") + stored = remission.apply_remission_for_phase(conn, phase_id, signing_key) + if stored is not None: + record_audit_event( + conn, licensor, action="apply_policy_remission", phase_id=phase_id, + trust_service_record_id=stored["id"], + detail={"type": stored["type"], "amount": stored["amount"]}, + ) + else: + record_audit_event( + conn, licensor, action="apply_policy_remission", phase_id=phase_id, + detail={"status": "up_to_date"}, + ) + return stored + + +# --- Extension Registry (WP-0014-T01) --------------------------------------- +# +# Rights decision (recorded 2026-08-05, WP-0014-T01; fills the gap noted in +# specs/PhaseLifecycleUseCases.md use case 6 and concept §2): +# - Register a new extension: Operator+ (same tier as Phase registration — +# the tenant is publishing structure it will use on its ledgers). +# - Promote to canonical: Admin only (governance action, never automated; +# matches credential management and set_extension_status SECURITY DEFINER). +# The Trust Service's own API still accepts any authenticated tenant token for +# register_extension; these gates are Control Plane policy layered on top. + + +def register_extension( + conn: "Connection", licensor: Licensor, extension: dict[str, Any] +) -> None: + """Operator+: register a Monetization Extension (starts as `registered`).""" + _require_right(licensor, "operator") + registry.register_extension(conn, licensor, extension) + record_audit_event( + conn, licensor, action="register_extension", + detail={"extension_id": extension["id"], "version": extension["version"]}, + ) + + +def promote_extension_canonical( + conn: "Connection", admin: Licensor, extension_id: str, version: str +) -> None: + """Admin only: promote an extension from `registered` to `canonical`.""" + _require_right(admin, "admin") + approved_by = admin.credential_label or admin.token + registry.promote_extension_canonical(conn, extension_id, version, approved_by) + record_audit_event( + conn, admin, action="promote_extension_canonical", + detail={"extension_id": extension_id, "version": version}, + ) + + +# --- Breach / Compliance Records (WP-0014-T02) ------------------------------ + + +def publish_breach_event( + conn: "Connection", + licensor: Licensor, + phase_id: str, + event_input: dict[str, Any], + signing_key: Any, +) -> dict[str, Any]: + """Operator+: publish one breach/compliance lifecycle event (concept §2).""" + _require_right(licensor, "operator") + stored = breach_record.publish_breach_event( + conn, licensor, phase_id, event_input, signing_key + ) + record_audit_event( + conn, licensor, action="publish_breach_event", phase_id=phase_id, + trust_service_record_id=stored["id"], + detail={ + "case_id": stored["case_id"], + "event_type": stored["event_type"], + "anonymized": stored["anonymized"], + }, + ) + return stored + + +# --- Conversion Attestation (WP-0014-T03) ----------------------------------- + + +def get_or_publish_attestation( + conn: "Connection", + phase_id: str, + signing_key: Any, +) -> dict[str, Any] | None: + """Read-only UI helper: return the published attestation if converted. + + Calls `attestation.publish_attestation` which is idempotent and only + writes on first observation of Outstanding Target = 0. Returns None + when the Phase has not converted (does not raise). No rights check — + attestations are public facts (FR-9); any signed-in Viewer may see them. + """ + manifest = registry.get_phase_manifest(conn, phase_id) + if manifest is None: + return None + entries = ledger.get_ledger(conn, phase_id) + try: + return attestation.publish_attestation(conn, manifest, entries, signing_key) + except attestation.NotConvertedError: + return None + + def propose_ledger_entry( conn: "Connection", licensor: Licensor, phase_id: str, entry_input: dict[str, Any] ) -> int: diff --git a/src/target_revenue/metrics.py b/src/target_revenue/metrics.py index 970eb8a..9bb0256 100644 --- a/src/target_revenue/metrics.py +++ b/src/target_revenue/metrics.py @@ -25,6 +25,7 @@ from typing import Any from . import conversion as conversion_module from . import fold as fold_module +from . import remission as remission_module _MATERIAL_PROGRESS_TYPES = frozenset({"development-credit", "remission-credit"}) @@ -34,9 +35,17 @@ def _parse(ts: str) -> datetime: def compute_metrics( - manifest: dict[str, Any], entries: list[dict[str, Any]], as_of: datetime + manifest: dict[str, Any], + entries: list[dict[str, Any]], + as_of: datetime, + activated_at: datetime | None = None, ) -> dict[str, Any]: - """Compute the full labeled metrics set for one Phase at a point in time.""" + """Compute the full labeled metrics set for one Phase at a point in time. + + `activated_at` is optional Phase activation (t0). Hosted callers pass + `phase_manifests.registered_at` (WP-0013). When omitted, policy-based + remission forecasts are left null rather than inventing a t0. + """ if as_of.tzinfo is None: raise ValueError("as_of must be timezone-aware") @@ -61,6 +70,7 @@ def compute_metrics( "future_license": status.future_license, "last_ledger_entry_id": last_entry["id"] if last_entry else None, "longstop_at": manifest["phase"].get("longstop_at"), + "activated_at": activated_at.isoformat() if activated_at is not None else None, } calculations: dict[str, Any] = { @@ -95,7 +105,12 @@ def compute_metrics( (as_of - _parse(last_material["recognized_at"])).total_seconds() / 86400.0, 4 ) - forecasts: dict[str, Any] = {"projected_conversion_date": None} + forecasts: dict[str, Any] = { + "projected_conversion_date": None, + "remission_if_applied_now": None, + "next_scheduled_remission_at": None, + "next_scheduled_remission_amount": None, + } velocity = ( (calculations["development_credit_velocity_per_day"] or 0.0) + (calculations["remission_credit_velocity_per_day"] or 0.0) @@ -106,6 +121,28 @@ def compute_metrics( as_of.replace(microsecond=0) + _timedelta_days(days_remaining) ).isoformat() + if activated_at is not None: + plan_now = remission_module.plan_remission( + manifest, entries, t0=activated_at, as_of=as_of + ) + if plan_now is not None: + forecasts["remission_if_applied_now"] = ( + plan_now.delta if plan_now.should_append else 0.0 + ) + longstop = _parse(manifest["phase"]["longstop_at"]) + next_at = remission_module.next_scheduled_remission_at(as_of, longstop) + if next_at is not None and not status.is_converted: + forecasts["next_scheduled_remission_at"] = next_at.isoformat() + plan_next = remission_module.plan_remission( + manifest, entries, t0=activated_at, as_of=next_at + ) + if plan_next is not None: + # Amount that would still be due at the next boundary if + # nothing else is remitted between now and then. + forecasts["next_scheduled_remission_amount"] = ( + plan_next.delta if plan_next.should_append else 0.0 + ) + return { "phase": manifest["phase"]["id"], "as_of": as_of.isoformat(), diff --git a/src/target_revenue/registry.py b/src/target_revenue/registry.py index 7355efb..6896ea3 100644 --- a/src/target_revenue/registry.py +++ b/src/target_revenue/registry.py @@ -280,6 +280,35 @@ def get_extension( return {**contract, "status": status} +def list_extensions(conn: Connection) -> list[dict[str, Any]]: + """All registered extensions (any Licensor), most recently registered first. + + Status is the hosting-layer column (authoritative for canonical/ + registered/deprecated), not the contract JSON's own `status` field + which is only what the author submitted. + """ + rows = conn.execute( + """ + SELECT extension_id, version, licensor_id, contract, status, registered_at + FROM extensions + ORDER BY registered_at DESC, extension_id ASC, version ASC + """ + ).fetchall() + result = [] + for extension_id, version, licensor_id, contract, status, registered_at in rows: + result.append( + { + "extension_id": extension_id, + "version": version, + "licensor_id": licensor_id, + "contract": contract, + "status": status, + "registered_at": registered_at, + } + ) + return result + + def promote_extension_canonical( conn: Connection, extension_id: str, version: str, approved_by: str ) -> None: diff --git a/src/target_revenue/remission.py b/src/target_revenue/remission.py new file mode 100644 index 0000000..df52139 --- /dev/null +++ b/src/target_revenue/remission.py @@ -0,0 +1,367 @@ +"""Remission Credit calculation and ledger application (WP-0013). + +Implements `trsl:policy:linear-longstop-v0@1.0` from +`specs/policies/linear-longstop-v0.md`: + + R(t) = T0 × clamp((t − t0)/(tL − t0), 0, 1) + +Design decisions (WP-0013-T01, recorded 2026-08-05): + +1. **t0 (Phase activation)** is the Trust Service registration timestamp + (`phase_manifests.registered_at`). No new manifest field: in Stage 0 a + Phase is not active until registered. Pure callers pass `t0` explicitly + so offline packages stay free of hosting state. + +2. **Cadence** is monthly UTC by convention for scheduled runs (1st of + each month 00:00 UTC, or longstop if sooner). The pure model itself is + *cumulative*, not period-keyed: each invocation remits + `max(0, R(as_of) − already_recorded_policy_remission)`. Re-running at + the same `as_of` is a no-op; catching up after a missed schedule works + without double-counting. On-demand apply uses the same delta formula. + +3. **Idempotency** follows from (2). Policy-produced entries are identified + by `type == remission-credit` and `extension.id` matching the policy + (corrections stay out of the "already remitted" sum so a deliberate + `remission-correction` is not silently undone by the next run). + +4. **Actor** is a dedicated Licensor credential labeled + `system:policy-engine` (rights: operator). Policy-driven entries are not + attributed to a human; `submitted_by_token` is never left null. The + credential is auto-issued on first use per Licensor tenant. +""" + +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from psycopg import Connection + + from .registry import Licensor + +#: Credential label for the non-human actor that submits policy remissions. +POLICY_ENGINE_CREDENTIAL_LABEL = "system:policy-engine" + +#: Dust floor: skip ledger writes smaller than this (currency major units). +MIN_REMISSION_AMOUNT = 0.01 + +LINEAR_LONGSTOP_V0_POLICY_ID = "trsl:policy:linear-longstop-v0" +LINEAR_LONGSTOP_V0_VERSION = "1.0" +LINEAR_LONGSTOP_V0_URN = f"{LINEAR_LONGSTOP_V0_POLICY_ID}@{LINEAR_LONGSTOP_V0_VERSION}" + + +@dataclass(frozen=True) +class RemissionPlan: + """Result of planning one policy-driven remission at a point in time.""" + + policy_id: str + policy_version: str + cumulative_expected: float + already_remitted: float + delta: float + as_of: datetime + t0: datetime + longstop_at: datetime + initial_target: float + + @property + def should_append(self) -> bool: + return self.delta >= MIN_REMISSION_AMOUNT + + +def parse_policy_urn(urn: str) -> tuple[str, str]: + """Split `trsl:policy:slug@version` into (id, version).""" + if "@" not in urn: + raise ValueError(f"policy URN missing @version: {urn!r}") + policy_id, version = urn.rsplit("@", 1) + if not policy_id.startswith("trsl:policy:"): + raise ValueError(f"not a degeneration policy URN: {urn!r}") + return policy_id, version + + +def _parse_ts(ts: str | datetime) -> datetime: + if isinstance(ts, datetime): + if ts.tzinfo is None: + raise ValueError("timestamps must be timezone-aware") + return ts + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + if dt.tzinfo is None: + raise ValueError("timestamps must be timezone-aware") + return dt + + +def clamp01(x: float) -> float: + return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x + + +def cumulative_remission( + initial_target: float, + t0: datetime, + longstop_at: datetime, + as_of: datetime, +) -> float: + """R(t) for linear-longstop-v0. Pure; no I/O. + + Before t0 → 0; at/after longstop → full Initial Target; linear in between. + """ + t0 = _parse_ts(t0) + longstop_at = _parse_ts(longstop_at) + as_of = _parse_ts(as_of) + if initial_target <= 0: + raise ValueError("initial_target must be positive") + span = (longstop_at - t0).total_seconds() + if span <= 0: + # Degenerate longstop at or before activation: full remission once as_of >= t0. + return float(initial_target) if as_of >= t0 else 0.0 + progress = clamp01((as_of - t0).total_seconds() / span) + return float(initial_target) * progress + + +def policy_remission_already_recorded( + entries: list[dict[str, Any]], + policy_id: str = LINEAR_LONGSTOP_V0_POLICY_ID, +) -> float: + """Sum of `remission-credit` amounts produced by this policy id. + + Does not include `remission-correction` or + `administrative-correction-remission` — those are deliberate human + adjustments that must not be auto-undone by the next policy run. + """ + total = 0.0 + for entry in entries: + if entry.get("type") != "remission-credit": + continue + ext = entry.get("extension") or {} + if ext.get("id") == policy_id: + total += float(entry["amount"]) + return total + + +def plan_remission( + manifest: dict[str, Any], + entries: list[dict[str, Any]], + t0: datetime, + as_of: datetime, +) -> RemissionPlan | None: + """Plan the next policy-driven remission for a Phase, or None if N/A. + + Returns None when the Phase's degeneration_policy is not a supported + automated policy (today: only linear-longstop-v0). Raises ValueError + on missing longstop / bad timestamps. + """ + policy_urn = manifest["phase"]["degeneration_policy"] + try: + policy_id, policy_version = parse_policy_urn(policy_urn) + except ValueError: + return None + if policy_id != LINEAR_LONGSTOP_V0_POLICY_ID: + return None + + initial = float(manifest["phase"]["initial_target"]["amount"]) + longstop_at = _parse_ts(manifest["phase"]["longstop_at"]) + t0_dt = _parse_ts(t0) + as_of_dt = _parse_ts(as_of) + + expected = cumulative_remission(initial, t0_dt, longstop_at, as_of_dt) + already = policy_remission_already_recorded(entries, policy_id) + # Never remit more than still outstanding against Initial Target when + # other credits already reduced it — fold clamps Outstanding at 0, but + # over-remitting relative to T0 would still inflate Remission Credit + # facts. Cap cumulative expected at T0 (formula already does) and delta + # at max(0, T0 - already) is implicit. Also do not reverse over-remission + # via negative delta (corrections handle that). + delta = max(0.0, expected - already) + # Round to cents to avoid float dust ledger spam. + delta = round(delta, 2) + expected = round(expected, 2) + + return RemissionPlan( + policy_id=policy_id, + policy_version=policy_version, + cumulative_expected=expected, + already_remitted=round(already, 2), + delta=delta, + as_of=as_of_dt, + t0=t0_dt, + longstop_at=longstop_at, + initial_target=initial, + ) + + +def build_remission_entry_input( + phase_id: str, + plan: RemissionPlan, + currency: str, + entry_id: str | None = None, +) -> dict[str, Any] | None: + """Build a ledger entry input (no previous_entry_hash/signature). + + Returns None when `plan.should_append` is false (idempotent no-op). + """ + if not plan.should_append: + return None + if entry_id is None: + entry_id = f"trsl:entry:rem{secrets.token_hex(12)}" + as_of_iso = plan.as_of.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return { + "id": entry_id, + "phase": phase_id, + "type": "remission-credit", + "amount": plan.delta, + "currency": currency, + "recognized_at": as_of_iso, + "extension": { + "id": plan.policy_id, + "version": plan.policy_version, + }, + "evidence_reference": ( + f"{plan.policy_id}@{plan.policy_version}" + f"#as_of={as_of_iso}" + f"#cumulative={plan.cumulative_expected}" + ), + } + + +def next_monthly_boundary(as_of: datetime) -> datetime: + """Next 1st-of-month 00:00 UTC strictly after `as_of`.""" + as_of = _parse_ts(as_of).astimezone(timezone.utc) + if as_of.month == 12: + candidate = datetime(as_of.year + 1, 1, 1, tzinfo=timezone.utc) + else: + candidate = datetime(as_of.year, as_of.month + 1, 1, tzinfo=timezone.utc) + # If as_of is exactly on a boundary, still move to the *next* one + # (scheduled run at T has already been eligible for that instant). + if as_of >= candidate: + if candidate.month == 12: + candidate = datetime(candidate.year + 1, 1, 1, tzinfo=timezone.utc) + else: + candidate = datetime(candidate.year, candidate.month + 1, 1, tzinfo=timezone.utc) + return candidate + + +def next_scheduled_remission_at(as_of: datetime, longstop_at: datetime) -> datetime | None: + """Next scheduled recognition instant: min(next monthly UTC, longstop), if still future.""" + as_of = _parse_ts(as_of) + longstop_at = _parse_ts(longstop_at) + if as_of >= longstop_at: + return None + monthly = next_monthly_boundary(as_of) + return monthly if monthly < longstop_at else longstop_at + + +# --- Hosted application path ------------------------------------------------ + + +def get_phase_registered_at(conn: "Connection", phase_id: str) -> datetime | None: + row = conn.execute( + "SELECT registered_at FROM phase_manifests WHERE phase_id = %s", + (phase_id,), + ).fetchone() + return row[0] if row else None + + +def ensure_policy_engine_credential(conn: "Connection", licensor_id: str) -> "Licensor": + """Return the `system:policy-engine` credential for a Licensor, creating it if needed.""" + from . import registry + from .registry import Licensor + + row = conn.execute( + """ + SELECT token, licensor_id, credential_label, rights + FROM licensors + WHERE licensor_id = %s + AND credential_label = %s + AND revoked_at IS NULL + LIMIT 1 + """, + (licensor_id, POLICY_ENGINE_CREDENTIAL_LABEL), + ).fetchone() + if row is not None: + token, lid, label, rights = row + return Licensor(token=token, licensor_id=lid, credential_label=label, rights=rights) + return registry.issue_sub_credential( + conn, + licensor_id=licensor_id, + credential_label=POLICY_ENGINE_CREDENTIAL_LABEL, + rights="operator", + issued_by="system:remission-automation", + ) + + +def apply_remission_for_phase( + conn: "Connection", + phase_id: str, + signing_key: "Ed25519PrivateKey", + as_of: datetime | None = None, +) -> dict[str, Any] | None: + """Compute and append a policy remission entry for one Phase if needed. + + Returns the signed entry if one was written, else None (already current, + unsupported policy, or phase missing). Uses the Phase owner's + `system:policy-engine` credential for `submitted_by_token`. + """ + from . import ledger, registry + from .registry import RegistrationError + + if as_of is None: + as_of = datetime.now(timezone.utc) + else: + as_of = _parse_ts(as_of) + + manifest = registry.get_phase_manifest(conn, phase_id) + if manifest is None: + raise RegistrationError(f"phase {phase_id!r} is not registered") + + registered_at = get_phase_registered_at(conn, phase_id) + if registered_at is None: + raise RegistrationError(f"phase {phase_id!r} has no registered_at") + if registered_at.tzinfo is None: + registered_at = registered_at.replace(tzinfo=timezone.utc) + + entries = ledger.get_ledger(conn, phase_id) + plan = plan_remission(manifest, entries, t0=registered_at, as_of=as_of) + if plan is None: + raise RegistrationError( + f"phase {phase_id!r} degeneration_policy is not an automated policy " + f"(supported: {LINEAR_LONGSTOP_V0_URN})" + ) + + entry_input = build_remission_entry_input( + phase_id, + plan, + currency=manifest["phase"]["initial_target"]["currency"], + ) + if entry_input is None: + return None + + licensor_id = conn.execute( + "SELECT licensor_id FROM phase_manifests WHERE phase_id = %s", + (phase_id,), + ).fetchone()[0] + engine = ensure_policy_engine_credential(conn, licensor_id) + return ledger.append_entry(conn, engine, phase_id, entry_input, signing_key) + + +def apply_remission_for_all_phases( + conn: "Connection", + signing_key: "Ed25519PrivateKey", + as_of: datetime | None = None, +) -> list[dict[str, Any]]: + """Run policy remission for every registered Phase. Returns appended entries.""" + from .registry import RegistrationError + + rows = conn.execute("SELECT phase_id FROM phase_manifests ORDER BY phase_id").fetchall() + written: list[dict[str, Any]] = [] + for (phase_id,) in rows: + try: + entry = apply_remission_for_phase(conn, phase_id, signing_key, as_of=as_of) + except RegistrationError: + # Skip Phases with unsupported policies rather than aborting the batch. + continue + if entry is not None: + written.append(entry) + return written diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py index d013b79..8403f93 100644 --- a/src/target_revenue/service/app.py +++ b/src/target_revenue/service/app.py @@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request from psycopg import Connection from psycopg_pool import ConnectionPool -from .. import attestation, breach_record, ledger, metrics, registry +from .. import attestation, breach_record, ledger, metrics, registry, remission from . import keys app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0") @@ -106,6 +106,12 @@ def read_extension( return result +@app.get("/healthz") +def healthz() -> dict[str, str]: + """Liveness/readiness probe target (WP-0011) — no auth, no DB.""" + return {"status": "ok"} + + @app.get("/public-key") def read_public_key() -> dict[str, str]: """The Ed25519 public key ledger entry signatures verify against. @@ -152,7 +158,65 @@ def read_metrics( if manifest is None: raise HTTPException(status_code=404, detail="phase not found") entries = ledger.get_ledger(conn, phase_id) - return metrics.compute_metrics(manifest, entries, metrics.utcnow()) + activated_at = remission.get_phase_registered_at(conn, phase_id) + return metrics.compute_metrics( + manifest, entries, metrics.utcnow(), activated_at=activated_at + ) + + +@app.post("/phases/{phase_id}/remission", status_code=201) +def apply_phase_remission( + phase_id: str, + licensor: registry.Licensor = Depends(get_licensor), + conn: Connection = Depends(get_connection), + signing_key=Depends(get_signing_key), +) -> dict[str, Any]: + """On-demand linear-longstop remission apply (WP-0013). + + Auth required (Operator+ of the Phase's Licensor). The ledger entry + itself is attributed to `system:policy-engine`, not the calling human + — the caller only authorizes the run. Returns 200-shaped body with + `status: up_to_date` and no entry when the Phase is already current + (idempotent). + """ + if not registry.has_right(licensor.rights, "operator"): + raise HTTPException(status_code=403, detail="operator rights required") + manifest = registry.get_phase_manifest(conn, phase_id) + if manifest is None: + raise HTTPException(status_code=404, detail="phase not found") + owner = conn.execute( + "SELECT licensor_id FROM phase_manifests WHERE phase_id = %s", (phase_id,) + ).fetchone()[0] + if owner != licensor.licensor_id: + raise HTTPException(status_code=403, detail="not authorized for this Phase") + try: + entry = remission.apply_remission_for_phase(conn, phase_id, signing_key) + except registry.RegistrationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if entry is None: + return {"phase_id": phase_id, "status": "up_to_date", "entry": None} + return {"phase_id": phase_id, "status": "appended", "entry": entry} + + +@app.post("/remission/run", status_code=200) +def run_remission_batch( + licensor: registry.Licensor = Depends(get_licensor), + conn: Connection = Depends(get_connection), + signing_key=Depends(get_signing_key), +) -> dict[str, Any]: + """Batch scheduled-style run across all Phases (WP-0013). + + Intended for a cron job holding an Operator+ credential. Each Phase + is planned independently; unsupported policies are skipped. + """ + if not registry.has_right(licensor.rights, "operator"): + raise HTTPException(status_code=403, detail="operator rights required") + written = remission.apply_remission_for_all_phases(conn, signing_key) + return { + "status": "ok", + "entries_appended": len(written), + "entry_ids": [e["id"] for e in written], + } @app.get("/phases/{phase_id}/attestation") diff --git a/src/target_revenue/service/combined.py b/src/target_revenue/service/combined.py new file mode 100644 index 0000000..f59bf3c --- /dev/null +++ b/src/target_revenue/service/combined.py @@ -0,0 +1,32 @@ +"""Single-process ASGI app: Trust Service + Control Plane (WP-0011). + +Both FastAPI apps remain independently importable for local/dev +(`app.py`, `control_plane_app.py`). This module composes them for the +container entrypoint: + +- ``GET /healthz`` — liveness/readiness (no auth, no DB) +- ``/ui/*`` — Control Plane (session UI) +- ``/*`` — Trust Service public/API surface + +Ingress may also expose path-based routing; the in-process mount is the +default so a single Service port works without Traefik strip-prefix. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from .app import app as trust_service_app +from .control_plane_app import app as control_plane_app + +app = FastAPI(title="Target Revenue — Trust Service + Control Plane", version="0.1.0") + + +@app.get("/healthz") +def healthz() -> dict[str, str]: + return {"status": "ok"} + + +# More specific mount first. +app.mount("/ui", control_plane_app) +app.mount("/", trust_service_app) diff --git a/src/target_revenue/service/control_plane_app.py b/src/target_revenue/service/control_plane_app.py index 2358883..402d646 100644 --- a/src/target_revenue/service/control_plane_app.py +++ b/src/target_revenue/service/control_plane_app.py @@ -25,7 +25,7 @@ from psycopg import Connection from psycopg_pool import ConnectionPool from starlette.middleware.sessions import SessionMiddleware -from .. import control_plane, ledger, metrics, registry +from .. import breach_record, control_plane, ledger, metrics, registry from . import keys, reference_docs _STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") @@ -107,9 +107,15 @@ def require_login( return licensor +def _root_path(request: Request) -> str: + """URL prefix when mounted under combined.py ``/ui`` (WP-0011).""" + return request.scope.get("root_path", "") or "" + + def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]: context = { "request": request, + "root_path": _root_path(request), "session_credential_label": licensor.credential_label if licensor else None, "session_rights": licensor.rights if licensor else None, "session_licensor_id": licensor.licensor_id if licensor else None, @@ -124,12 +130,24 @@ def _redirect(url: str, request: Request, flash: str | None = None, variant: str if flash: request.session["flash"] = flash request.session["flash_variant"] = variant + # Prefix relative Control Plane paths when mounted under /ui. + if url.startswith("/") and not url.startswith("//"): + url = _root_path(request) + url return RedirectResponse(url=url, status_code=303) @app.exception_handler(_NotAuthenticated) async def _redirect_to_login(request: Request, exc: _NotAuthenticated): - return RedirectResponse(url="/login", status_code=303) + return RedirectResponse(url=_root_path(request) + "/login", status_code=303) + + +# --- Health (WP-0011) ------------------------------------------------------- + + +@app.get("/healthz") +def healthz() -> dict[str, str]: + """Liveness/readiness probe target — no auth, no DB.""" + return {"status": "ok"} # --- Auth -------------------------------------------------------------- @@ -138,7 +156,7 @@ async def _redirect_to_login(request: Request, exc: _NotAuthenticated): @app.get("/login") def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)): if licensor is not None: - return RedirectResponse(url="/", status_code=303) + return _redirect("/", request) return templates.TemplateResponse(request, "login.html", _template_context(request, None)) @@ -151,17 +169,15 @@ def login_submit( try: registry.authenticate(conn, token) except registry.RegistrationError: - request.session["flash"] = "Invalid or revoked credential token." - request.session["flash_variant"] = "danger" - return RedirectResponse(url="/login", status_code=303) + return _redirect("/login", request, "Invalid or revoked credential token.", "danger") request.session["token"] = token - return RedirectResponse(url="/", status_code=303) + return _redirect("/", request) @app.post("/logout") def logout(request: Request): request.session.clear() - return RedirectResponse(url="/login", status_code=303) + return _redirect("/login", request) # --- Dashboard ----------------------------------------------------------- @@ -248,19 +264,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 +351,133 @@ def phase_ledger_submit( return _redirect(f"/phases/{phase_id}", request, flash, "success") +@app.post("/phases/{phase_id}/breach") +def phase_breach_publish( + phase_id: str, + request: Request, + licensor: registry.Licensor = Depends(require_login), + conn: Connection = Depends(get_connection), + signing_key=Depends(get_signing_key), + record_id: str = Form(...), + case_id: str = Form(...), + event_type: str = Form(...), + category: str = Form(...), + event_at: str = Form(...), + evidence_reference: str = Form(""), + anonymized: str = Form("true"), + named_entitlement_holder: str = Form(""), + named_disclosure_authorized: str = Form(""), +): + """Operator+: publish a breach/compliance event (WP-0014-T02).""" + is_anonymized = anonymized.lower() in ("true", "1", "on", "yes") + event_input: dict[str, Any] = { + "id": record_id, + "case_id": case_id, + "event_type": event_type, + "category": category, + "event_at": event_at, + "anonymized": is_anonymized, + } + if evidence_reference: + event_input["evidence_reference"] = evidence_reference + if not is_anonymized: + event_input["named_entitlement_holder"] = named_entitlement_holder + event_input["named_disclosure_authorized_under_cua"] = ( + named_disclosure_authorized.lower() in ("true", "1", "on", "yes") + ) + try: + stored = control_plane.publish_breach_event( + conn, licensor, phase_id, event_input, signing_key + ) + except (control_plane.ControlPlaneError, registry.RegistrationError) as exc: + return _redirect(f"/phases/{phase_id}", request, str(exc), "danger") + return _redirect( + f"/phases/{phase_id}", + request, + f"Breach record {stored['id']} published ({stored['event_type']}).", + "success", + ) + + +# --- Extension Registry (WP-0014-T01) ---------------------------------------- + + +@app.get("/extensions") +def extensions_list( + request: Request, + licensor: registry.Licensor = Depends(require_login), + conn: Connection = Depends(get_connection), +): + extensions = registry.list_extensions(conn) + return templates.TemplateResponse( + request, + "extensions.html", + _template_context(request, licensor, extensions=extensions), + ) + + +@app.post("/extensions") +def extensions_register( + request: Request, + licensor: registry.Licensor = Depends(require_login), + conn: Connection = Depends(get_connection), + extension_id: str = Form(...), + version: str = Form(...), + value_description: str = Form(...), + pricing_method: str = Form(...), + allocation_rule: str = Form(...), + default_rate: str = Form(""), + recognition_event: str = Form(...), + reversal_rule: str = Form(...), + evidence_requirement: str = Form(...), +): + extension: dict[str, Any] = { + "id": extension_id, + "version": version, + "value": {"description": value_description}, + "pricing": {"method": pricing_method}, + "allocation": {"rule": allocation_rule}, + "recognition": {"event": recognition_event}, + "reversal": {"rule": reversal_rule}, + "evidence": {"requirement": evidence_requirement}, + # Author-submitted status is always `registered`; the hosting column + # is authoritative and starts registered regardless (registry.py). + "status": "registered", + } + if default_rate.strip(): + extension["allocation"]["default_rate"] = float(default_rate) + try: + control_plane.register_extension(conn, licensor, extension) + except (control_plane.ControlPlaneError, registry.RegistrationError) as exc: + return _redirect("/extensions", request, str(exc), "danger") + return _redirect( + "/extensions", + request, + f"Extension {extension_id}@{version} registered.", + "success", + ) + + +@app.post("/extensions/promote") +def extensions_promote( + request: Request, + licensor: registry.Licensor = Depends(require_login), + conn: Connection = Depends(get_connection), + extension_id: str = Form(...), + version: str = Form(...), +): + try: + control_plane.promote_extension_canonical(conn, licensor, extension_id, version) + except (control_plane.ControlPlaneError, registry.RegistrationError) as exc: + return _redirect("/extensions", request, str(exc), "danger") + return _redirect( + "/extensions", + request, + f"Extension {extension_id}@{version} promoted to canonical.", + "success", + ) + + # --- Proposals (Operator+) -------------------------------------------------- diff --git a/src/target_revenue/service/control_plane_templates/admin_credentials.html b/src/target_revenue/service/control_plane_templates/admin_credentials.html index 6669383..f5c1f3f 100644 --- a/src/target_revenue/service/control_plane_templates/admin_credentials.html +++ b/src/target_revenue/service/control_plane_templates/admin_credentials.html @@ -13,7 +13,7 @@ {% endif %}