diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 61c14da..0000000 --- a/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -# 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 3fd714c..774b2c4 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 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-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-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 22a51f3..105e5e0 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 | finished | — | workplans/TREV-WP-0008-governance-and-pilot-rollout.md | +| workplan | TREV-WP-0008 | active | — | 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 | 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-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 deleted file mode 100644 index b822eea..0000000 --- a/docs/deployment.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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 deleted file mode 100644 index 73b91f2..0000000 --- a/k8s/railiance/bootstrap-job.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# 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 deleted file mode 100644 index e153794..0000000 --- a/k8s/railiance/cnpg-cluster.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index d6796c3..0000000 --- a/k8s/railiance/deployment.yaml +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index f471265..0000000 --- a/k8s/railiance/externalsecret.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index dfa95f8..0000000 --- a/k8s/railiance/ingress.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100644 index 5de0d14..0000000 --- a/k8s/railiance/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 7daf9b3..0000000 --- a/k8s/railiance/migrate-job.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index 73eb039..0000000 --- a/k8s/railiance/service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100755 index 364cc0f..0000000 --- a/scripts/apply_migrations.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/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 deleted file mode 100755 index cb91995..0000000 --- a/scripts/bootstrap_binky.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/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 deleted file mode 100755 index 75e4410..0000000 --- a/scripts/docker-entrypoint.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/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 14efb53..3e7c787 100644 --- a/specs/PhaseLifecycleUseCases.md +++ b/specs/PhaseLifecycleUseCases.md @@ -137,9 +137,12 @@ attestation about it"). `attestation.publish_attestation` is implemented, tested, and exposed at `GET /phases/{id}/attestation` — idempotent, publish-on-first-observation, never regenerated. -**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. +**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.** --- @@ -152,14 +155,22 @@ SECURITY DEFINER, never a plain `UPDATE`) — the same pattern already used for credential revocation and proposed-entry review elsewhere in this project. -**Resolved 2026-08-05** (`workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md` -T01, all four tasks finished): +**Two things are missing, one of them a real open question, not just +UI**: -- 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. +- 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.** --- @@ -169,12 +180,14 @@ 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. Rights tier is Operator+ -(`specs/TargetRevenueControlPlaneConcept.md` §2). +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. -**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). +**Gap → `workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md` +T02.** --- @@ -221,12 +234,12 @@ conformance suite (`tests/test_hosted_conformance.py`). | # | Use case | Status | |---|---|---| -| 1 | First Phase for a repo | Done (WP-0015 provenance fields) | -| 2 | Successive Phase, same repo | Done (WP-0015 `base_phase_id`) | +| 1 | First Phase for a repo | Partially modeled — no repo/provenance fields (→ T02) | +| 2 | Successive Phase, same repo | Not modeled at all (→ T02) | | 3 | Register / propose / review (rights model) | Done (WP-0009) | -| 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) | +| 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) | | 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 62776de..8f99b74 100644 --- a/specs/TargetRevenueControlPlaneConcept.md +++ b/specs/TargetRevenueControlPlaneConcept.md @@ -45,19 +45,10 @@ T01, maintainer-accepted, as proposed): | Right | Can do | |---|---| -| 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) | +| 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) | | 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, 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. +| 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 | **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 9bbe0df..0c037f0 100644 --- a/specs/policies/linear-longstop-v0.md +++ b/specs/policies/linear-longstop-v0.md @@ -26,19 +26,10 @@ cohort, not merely a Stage 0 pilot placeholder. ## Status -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). +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. ## Superseded / next iteration diff --git a/src/target_revenue/control_plane.py b/src/target_revenue/control_plane.py index 445602a..41622a3 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 attestation, breach_record, ledger, registry, remission +from . import ledger, registry from .registry import Licensor if TYPE_CHECKING: @@ -168,120 +168,6 @@ 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 9bb0256..970eb8a 100644 --- a/src/target_revenue/metrics.py +++ b/src/target_revenue/metrics.py @@ -25,7 +25,6 @@ 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"}) @@ -35,17 +34,9 @@ def _parse(ts: str) -> datetime: def compute_metrics( - manifest: dict[str, Any], - entries: list[dict[str, Any]], - as_of: datetime, - activated_at: datetime | None = None, + manifest: dict[str, Any], entries: list[dict[str, Any]], as_of: datetime ) -> dict[str, Any]: - """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. - """ + """Compute the full labeled metrics set for one Phase at a point in time.""" if as_of.tzinfo is None: raise ValueError("as_of must be timezone-aware") @@ -70,7 +61,6 @@ 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] = { @@ -105,12 +95,7 @@ def compute_metrics( (as_of - _parse(last_material["recognized_at"])).total_seconds() / 86400.0, 4 ) - forecasts: dict[str, Any] = { - "projected_conversion_date": None, - "remission_if_applied_now": None, - "next_scheduled_remission_at": None, - "next_scheduled_remission_amount": None, - } + forecasts: dict[str, Any] = {"projected_conversion_date": None} velocity = ( (calculations["development_credit_velocity_per_day"] or 0.0) + (calculations["remission_credit_velocity_per_day"] or 0.0) @@ -121,28 +106,6 @@ 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 6896ea3..7355efb 100644 --- a/src/target_revenue/registry.py +++ b/src/target_revenue/registry.py @@ -280,35 +280,6 @@ 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 deleted file mode 100644 index df52139..0000000 --- a/src/target_revenue/remission.py +++ /dev/null @@ -1,367 +0,0 @@ -"""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 8403f93..d013b79 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, remission +from .. import attestation, breach_record, ledger, metrics, registry from . import keys app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0") @@ -106,12 +106,6 @@ 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. @@ -158,65 +152,7 @@ def read_metrics( if manifest is None: raise HTTPException(status_code=404, detail="phase not found") entries = ledger.get_ledger(conn, phase_id) - 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], - } + return metrics.compute_metrics(manifest, entries, metrics.utcnow()) @app.get("/phases/{phase_id}/attestation") diff --git a/src/target_revenue/service/combined.py b/src/target_revenue/service/combined.py deleted file mode 100644 index f59bf3c..0000000 --- a/src/target_revenue/service/combined.py +++ /dev/null @@ -1,32 +0,0 @@ -"""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 402d646..2358883 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 breach_record, control_plane, ledger, metrics, registry +from .. import control_plane, ledger, metrics, registry from . import keys, reference_docs _STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") @@ -107,15 +107,9 @@ 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, @@ -130,24 +124,12 @@ 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=_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"} + return RedirectResponse(url="/login", status_code=303) # --- Auth -------------------------------------------------------------- @@ -156,7 +138,7 @@ def healthz() -> dict[str, str]: @app.get("/login") def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)): if licensor is not None: - return _redirect("/", request) + return RedirectResponse(url="/", status_code=303) return templates.TemplateResponse(request, "login.html", _template_context(request, None)) @@ -169,15 +151,17 @@ def login_submit( try: registry.authenticate(conn, token) except registry.RegistrationError: - return _redirect("/login", request, "Invalid or revoked credential token.", "danger") + request.session["flash"] = "Invalid or revoked credential token." + request.session["flash_variant"] = "danger" + return RedirectResponse(url="/login", status_code=303) request.session["token"] = token - return _redirect("/", request) + return RedirectResponse(url="/", status_code=303) @app.post("/logout") def logout(request: Request): request.session.clear() - return _redirect("/login", request) + return RedirectResponse(url="/login", status_code=303) # --- Dashboard ----------------------------------------------------------- @@ -264,56 +248,19 @@ 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) - 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 - ) + computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow()) return templates.TemplateResponse( request, "phase_detail.html", - _template_context( - request, - licensor, - manifest=manifest, - ledger=entries, - metrics=computed_metrics, - breaches=breaches, - attestation=phase_attestation, - ), + _template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics), ) -@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, @@ -351,133 +298,6 @@ 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 f5c1f3f..6669383 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 %}