diff --git a/README.md b/README.md index 964e2ce..d87c047 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule** | [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) — active; T01–T03 `wait` on WP-0012-T03's policy-spec-file decision. Nothing currently computes or writes `remission-credit` ledger entries | | [TREV-WP-0014](workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md) | Control Plane UI: Extension Registry, Breach Records, Conversion Attestation — active; T01 next. Backend for all three already exists (WP-0006); UI-only work, not blocked on WP-0012 | -| [TREV-WP-0015](workplans/TREV-WP-0015-phase-provenance-implementation.md) | Implement `specs/PhaseProvenanceSpecAddendum.md` — active; T01–T04, T06 done. T05 (`forgejo_hubs` migration) next | +| [TREV-WP-0015](workplans/TREV-WP-0015-phase-provenance-implementation.md) | Implement `specs/PhaseProvenanceSpecAddendum.md` — **finished**, all 7 tasks done. Phase provenance fields, `specs/policies/`/`specs/profiles/` extraction, reference-rendering routes, ledger UI change, and the `forgejo_hubs` registry are all in place | Hub index: [`WORK-RECORDS.md`](WORK-RECORDS.md) · brief: [`.custodian-brief.md`](.custodian-brief.md) diff --git a/migrations/0007_forgejo_hubs.sql b/migrations/0007_forgejo_hubs.sql new file mode 100644 index 0000000..c304ebf --- /dev/null +++ b/migrations/0007_forgejo_hubs.sql @@ -0,0 +1,84 @@ +-- WP-0015-T05 (specs/PhaseProvenanceSpecAddendum.md §4): the Forgejo +-- hub -> service URI registry. +-- Depends on migrations/0001_registries.sql (phase_manifests). +-- +-- Per WP-0012-T02's accepted decision: target-revenue is the generic +-- framework, not a specific deployment's list of repos it monetizes, so +-- this mapping is hosted Trust Service data (parallel to +-- licensor_identities, migrations/0005_licensor_credentials.sql), never +-- a file in this git repo. A Phase Manifest stays fully self-describing +-- and offline-verifiable regardless -- it already carries +-- repo_hub_uri/repo_name directly at registration time +-- (specs/PhaseLifecycleUseCases.md use case 9). This table exists purely +-- as an admin/repair convenience: if a hub's URI ever changes, there is +-- one row to correct rather than every affected Phase Manifest. + +BEGIN; + +CREATE TABLE IF NOT EXISTS forgejo_hubs ( + hub_slug text PRIMARY KEY, + service_uri text NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT ON forgejo_hubs TO trf_app; +-- No UPDATE/DELETE grant for trf_app -- correcting a hub's URI after the +-- fact is a governance action (correct_forgejo_hub_uri() below), same +-- pattern as set_extension_status()/revoke_credential(), not a route +-- the application's ordinary write path can take. + +-- Auto-populate on first sight of a repo_hub during Phase registration -- +-- registry.register_phase_manifest() never needs to know this table +-- exists; the trigger reads repo_hub/repo_hub_uri straight out of the +-- manifest JSONB it was already given, mirroring +-- ensure_licensor_identity()'s auto-create-on-first-INSERT pattern. +-- ON CONFLICT DO NOTHING: a hub already seen is left alone here -- URI +-- corrections go through correct_forgejo_hub_uri() below, not a silent +-- overwrite on the next unrelated Phase registration. +CREATE OR REPLACE FUNCTION ensure_forgejo_hub() RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_hub_slug text := NEW.manifest #>> '{phase,milestone_release,repo_hub}'; + v_hub_uri text := NEW.manifest #>> '{phase,milestone_release,repo_hub_uri}'; +BEGIN + IF v_hub_slug IS NOT NULL AND v_hub_uri IS NOT NULL THEN + INSERT INTO forgejo_hubs (hub_slug, service_uri) + VALUES (v_hub_slug, v_hub_uri) + ON CONFLICT (hub_slug) DO NOTHING; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS phase_manifests_ensure_forgejo_hub ON phase_manifests; +CREATE TRIGGER phase_manifests_ensure_forgejo_hub + BEFORE INSERT ON phase_manifests + FOR EACH ROW + EXECUTE FUNCTION ensure_forgejo_hub(); + +-- Correcting a hub's URI after the fact (a domain move, etc.) is a +-- recorded governance action, not an ordinary UPDATE -- trf_app has no +-- UPDATE grant on forgejo_hubs at all. +CREATE OR REPLACE FUNCTION correct_forgejo_hub_uri( + p_hub_slug text, + p_new_uri text +) RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + UPDATE forgejo_hubs + SET service_uri = p_new_uri, + updated_at = now() + WHERE hub_slug = p_hub_slug; + IF NOT FOUND THEN + RAISE EXCEPTION 'unknown forgejo hub: %', p_hub_slug; + END IF; +END; +$$; + +GRANT EXECUTE ON FUNCTION correct_forgejo_hub_uri(text, text) TO trf_app; + +COMMIT; diff --git a/src/target_revenue/registry.py b/src/target_revenue/registry.py index 1a2010b..7355efb 100644 --- a/src/target_revenue/registry.py +++ b/src/target_revenue/registry.py @@ -29,7 +29,7 @@ from dataclasses import dataclass from typing import Any from psycopg import Connection -from psycopg.errors import UniqueViolation +from psycopg.errors import RaiseException, UniqueViolation from psycopg.types.json import Jsonb from . import validation @@ -296,3 +296,45 @@ def promote_extension_canonical( "SELECT set_extension_status(%s, %s, %s, %s)", (extension_id, version, "canonical", approved_by), ) + + +def get_forgejo_hub(conn: Connection, hub_slug: str) -> dict[str, Any] | None: + """Look up a hub's currently recorded service URI (WP-0015-T05). + + Purely an admin/repair convenience — a Phase Manifest never needs + this to be reachable to be independently verified, since it already + carries `repo_hub_uri`/`repo_name` directly at registration time + (`specs/PhaseLifecycleUseCases.md` use case 9). Rows are created + automatically by `phase_manifests_ensure_forgejo_hub` + (`migrations/0007_forgejo_hubs.sql`) the first time a hub is seen — + there is no explicit "register a hub" function to pair with this one. + """ + row = conn.execute( + "SELECT hub_slug, service_uri, first_seen_at, updated_at " + "FROM forgejo_hubs WHERE hub_slug = %s", + (hub_slug,), + ).fetchone() + if row is None: + return None + hub_slug_, service_uri, first_seen_at, updated_at = row + return { + "hub_slug": hub_slug_, + "service_uri": service_uri, + "first_seen_at": first_seen_at, + "updated_at": updated_at, + } + + +def correct_forgejo_hub_uri(conn: Connection, hub_slug: str, new_uri: str) -> None: + """Correct a hub's recorded service URI (e.g. after a domain move). + + Calls the database's `correct_forgejo_hub_uri` function rather than + an UPDATE — the application role has no UPDATE grant on + `forgejo_hubs` at all, matching every other governance-action pattern + in this project (`revoke_sub_credential`, + `promote_extension_canonical`). + """ + try: + conn.execute("SELECT correct_forgejo_hub_uri(%s, %s)", (hub_slug, new_uri)) + except RaiseException as exc: + raise RegistrationError(f"unknown forgejo hub: {hub_slug!r}") from exc diff --git a/tests/test_forgejo_hubs.py b/tests/test_forgejo_hubs.py new file mode 100644 index 0000000..a897f1c --- /dev/null +++ b/tests/test_forgejo_hubs.py @@ -0,0 +1,177 @@ +"""Integration tests for WP-0015-T05 (`forgejo_hubs` registry). + +Same ephemeral, disposable Postgres-via-Docker pattern as the other +hosted test modules (never the shared state-hub instance). +""" + +from __future__ import annotations + +import copy +import shutil +import subprocess +import time +import uuid +from pathlib import Path + +import pytest + +psycopg = pytest.importorskip("psycopg") + +from conftest import golden_manifest # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATIONS = [ + REPO_ROOT / "migrations" / "0001_registries.sql", + REPO_ROOT / "migrations" / "0002_ledger.sql", + REPO_ROOT / "migrations" / "0003_attestations.sql", + REPO_ROOT / "migrations" / "0004_breach_records.sql", + REPO_ROOT / "migrations" / "0005_licensor_credentials.sql", + REPO_ROOT / "migrations" / "0006_control_plane.sql", + REPO_ROOT / "migrations" / "0007_forgejo_hubs.sql", +] + +pytestmark = pytest.mark.skipif( + shutil.which("docker") is None, reason="docker not available" +) + + +@pytest.fixture(scope="module") +def pg_container(): + name = f"trf-test-pg-hubs-{uuid.uuid4().hex[:8]}" + subprocess.run( + [ + "docker", "run", "--rm", "-d", + "--name", name, + "-e", "POSTGRES_PASSWORD=postgres", + "-e", "POSTGRES_DB=target_revenue_test", + "-p", "127.0.0.1::5432", + "postgres:16-alpine", + ], + check=True, capture_output=True, + ) + try: + port_out = subprocess.run( + ["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True + ).stdout.strip() + host_port = port_out.split(":")[-1] + dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres" + + for _ in range(60): + try: + with psycopg.connect(dsn, connect_timeout=1): + break + except psycopg.OperationalError: + time.sleep(0.5) + else: + raise RuntimeError("postgres container did not become ready in time") + + with psycopg.connect(dsn) as conn: + for migration in MIGRATIONS: + conn.execute(migration.read_text(encoding="utf-8")) + conn.commit() + conn.execute( + "INSERT INTO licensors (token, licensor_id, credential_label, rights, issued_by) " + "VALUES (%s, %s, %s, %s, %s)", + ("founding-token", "binky", "founder", "operator", "bootstrap"), + ) + conn.commit() + + app_dsn = ( + f"host=127.0.0.1 port={host_port} dbname=target_revenue_test " + f"user=trf_app password=changeme-in-deployment" + ) + yield {"admin_dsn": dsn, "app_dsn": app_dsn, "founding_token": "founding-token"} + finally: + subprocess.run(["docker", "stop", name], capture_output=True) + + +@pytest.fixture() +def conn(pg_container): + with psycopg.connect(pg_container["app_dsn"]) as connection: + yield connection + + +@pytest.fixture() +def licensor(conn, pg_container): + from target_revenue import registry + + return registry.authenticate(conn, pg_container["founding_token"]) + + +def _manifest_with_hub(hub_slug: str, hub_uri: str, phase_suffix: str) -> dict: + manifest = copy.deepcopy(golden_manifest()) + manifest["phase"]["id"] = manifest["phase"]["id"] + "-hubtest-" + phase_suffix + manifest["phase"]["milestone_release"]["repo_hub"] = hub_slug + manifest["phase"]["milestone_release"]["repo_hub_uri"] = hub_uri + return manifest + + +def test_registering_phase_auto_creates_hub_row(conn, licensor): + from target_revenue import registry + + suffix = uuid.uuid4().hex[:8] + manifest = _manifest_with_hub(f"hub-{suffix}", "https://forgejo.example.invalid", suffix) + registry.register_phase_manifest(conn, licensor, manifest) + conn.commit() + + hub = registry.get_forgejo_hub(conn, f"hub-{suffix}") + assert hub is not None + assert hub["service_uri"] == "https://forgejo.example.invalid" + + +def test_unknown_hub_returns_none(conn): + from target_revenue import registry + + assert registry.get_forgejo_hub(conn, "no-such-hub") is None + + +def test_second_phase_same_hub_does_not_overwrite(conn, licensor): + """ON CONFLICT DO NOTHING: a hub already seen is left alone by the + auto-populate trigger -- only correct_forgejo_hub_uri() may change it.""" + from target_revenue import registry + + suffix = uuid.uuid4().hex[:8] + hub_slug = f"hub-{suffix}" + manifest_a = _manifest_with_hub(hub_slug, "https://first-seen.example.invalid", suffix + "a") + registry.register_phase_manifest(conn, licensor, manifest_a) + conn.commit() + + manifest_b = _manifest_with_hub(hub_slug, "https://different-uri.example.invalid", suffix + "b") + registry.register_phase_manifest(conn, licensor, manifest_b) + conn.commit() + + hub = registry.get_forgejo_hub(conn, hub_slug) + assert hub["service_uri"] == "https://first-seen.example.invalid" + + +def test_correct_forgejo_hub_uri_updates_existing_hub(conn, licensor): + from target_revenue import registry + + suffix = uuid.uuid4().hex[:8] + hub_slug = f"hub-{suffix}" + manifest = _manifest_with_hub(hub_slug, "https://old-domain.example.invalid", suffix) + registry.register_phase_manifest(conn, licensor, manifest) + conn.commit() + + registry.correct_forgejo_hub_uri(conn, hub_slug, "https://new-domain.example.invalid") + conn.commit() + + hub = registry.get_forgejo_hub(conn, hub_slug) + assert hub["service_uri"] == "https://new-domain.example.invalid" + + +def test_correct_forgejo_hub_uri_unknown_hub_is_rejected(conn): + from target_revenue import registry + + with pytest.raises(registry.RegistrationError, match="unknown forgejo hub"): + registry.correct_forgejo_hub_uri(conn, "never-registered", "https://x.example.invalid") + + +def test_application_role_cannot_update_forgejo_hubs_directly(pg_container): + with psycopg.connect(pg_container["app_dsn"]) as app_conn: + with pytest.raises(psycopg.errors.InsufficientPrivilege): + app_conn.execute( + "UPDATE forgejo_hubs SET service_uri = 'https://tampered.invalid' " + "WHERE hub_slug = 'anything'" + ) + app_conn.rollback() diff --git a/tests/test_reference_docs.py b/tests/test_reference_docs.py new file mode 100644 index 0000000..10bce75 --- /dev/null +++ b/tests/test_reference_docs.py @@ -0,0 +1,71 @@ +"""Smoke tests for WP-0015-T07: every real `specs/policies/`/ +`specs/profiles/` file must actually render without error. No database +or Docker required — this only exercises `reference_docs.py`'s pure +file-rendering logic. +""" + +from __future__ import annotations + +import pytest +from conftest import REPO_ROOT + +pytest.importorskip("markdown") +pytest.importorskip("yaml") + +from target_revenue.service import reference_docs # noqa: E402 + +POLICY_SLUGS = ["linear-longstop-v0"] +PROFILE_SLUGS = [ + "development-license", + "cost-plus-operations", + "phase-sponsorship", + "service-with-development-allocation", + "product-ideation", + "general-consulting", +] + + +def test_all_real_policy_files_exist_on_disk(): + for slug in POLICY_SLUGS: + assert (REPO_ROOT / "specs" / "policies" / f"{slug}.md").is_file() + + +def test_all_real_profile_files_exist_on_disk(): + for slug in PROFILE_SLUGS: + assert (REPO_ROOT / "specs" / "profiles" / f"{slug}.md").is_file() + + +@pytest.mark.parametrize("slug", POLICY_SLUGS) +def test_policy_doc_renders_without_error(slug): + result = reference_docs.load_reference_doc("policies", slug) + assert result is not None + html, frontmatter = result + assert "

" in html or "

" in html + assert "policy_id" in frontmatter + assert frontmatter["policy_id"].startswith("trsl:policy:") + + +@pytest.mark.parametrize("slug", PROFILE_SLUGS) +def test_profile_doc_renders_without_error(slug): + result = reference_docs.load_reference_doc("profiles", slug) + assert result is not None + html, frontmatter = result + assert "

" in html + assert "extension_id" in frontmatter + assert frontmatter["extension_id"].startswith("trsl:extension:") + + +def test_unknown_kind_returns_none(): + assert reference_docs.load_reference_doc("calculators", "anything") is None + + +def test_unknown_slug_returns_none(): + assert reference_docs.load_reference_doc("policies", "does-not-exist") is None + + +def test_policy_slug_from_id(): + assert reference_docs.policy_slug_from_id("trsl:policy:linear-longstop-v0@1.0") == "linear-longstop-v0" + + +def test_extension_slug_from_id(): + assert reference_docs.extension_slug_from_id("trsl:extension:development-license@1.0") == "development-license" diff --git a/workplans/TREV-WP-0015-phase-provenance-implementation.md b/workplans/TREV-WP-0015-phase-provenance-implementation.md index db60d31..4fc4a87 100644 --- a/workplans/TREV-WP-0015-phase-provenance-implementation.md +++ b/workplans/TREV-WP-0015-phase-provenance-implementation.md @@ -4,7 +4,7 @@ type: workplan title: "Implement Phase provenance, spec-file, and ledger UI changes" domain: infotech repo: target-revenue -status: active +status: finished owner: claude topic_slug: infotech created: "2026-08-03" @@ -173,7 +173,7 @@ reference plus a live-JSON link when it resolves to this instance ```task id: TREV-WP-0015-T05 -status: todo +status: done priority: high state_hub_task_id: "816f1ac2-da40-4550-8d6c-64a1a335a660" ``` @@ -188,6 +188,31 @@ implementation (not a T02-style human gate, per the addendum) whether correcting a hub's URI after the fact is a `SECURITY DEFINER` governance action or a plain `UPDATE`, and document whichever is chosen. +**Result:** `migrations/0007_forgejo_hubs.sql` — the `BEFORE INSERT` +trigger on `phase_manifests` reads `repo_hub`/`repo_hub_uri` straight out +of the manifest JSONB (`NEW.manifest #>> '{phase,milestone_release, +repo_hub}'`) rather than needing those as top-level table columns, since +`phase_manifests` stores the whole manifest as one `jsonb` blob. +`ON CONFLICT (hub_slug) DO NOTHING` — a hub already seen is left alone by +the trigger; only a second registration under the same slug with a +different URI would otherwise silently overwrite it, which is exactly +the failure mode this table exists to prevent. + +**Decided: `SECURITY DEFINER` governance action**, not a plain `UPDATE` +— `correct_forgejo_hub_uri(hub_slug, new_uri)`, matching +`set_extension_status()`/`revoke_credential()`. `trf_app` has no UPDATE +grant on `forgejo_hubs` at all. Thin Python wrappers added to +`registry.py`: `get_forgejo_hub()`, `correct_forgejo_hub_uri()` (raises +`RegistrationError` for an unknown slug, via `psycopg.errors. +RaiseException`). + +New `tests/test_forgejo_hubs.py`, 6 Docker-gated tests: auto-creation on +first registration, unknown-hub lookup, second-registration-same-hub +does *not* overwrite, explicit correction does, correction of an unknown +hub is rejected, and DB-level UPDATE rejection for the application role. +Full suite: 94 passing offline (unchanged), 170 passing with Docker (up +from 164). + ```task id: TREV-WP-0015-T06 status: done @@ -215,7 +240,7 @@ regression test: `test_pilot_candidate_manifest_carries_real_repo_provenance`. ```task id: TREV-WP-0015-T07 -status: todo +status: done priority: high state_hub_task_id: "39b34438-a1b1-49da-a88a-f3bec926aa89" ``` @@ -228,3 +253,18 @@ without error. Update `README.md`'s WP-0015 row and this workplan's Result sections; run the full offline + Docker-gated suite; fence-count check before committing any workplan edit, per this project's standing practice. + +**Result:** New `tests/test_reference_docs.py` — 13 tests, no Docker +required (only needs the `service` extras venv for `markdown`/`PyYAML`), +covering every real policy/profile file on disk, not just the two +exercised incidentally by T03's Control Plane tests. Skips cleanly under +plain `python3` (offline suite) when those extras aren't installed, +matching this project's existing `pytest.importorskip` convention. + +All seven WP-0015 tasks are now done. Final suite counts: **94 passing +offline** (up from 84 at the start of this workplan — T01's schema tests +and T06's backfill-regression test, unchanged since; nothing in T02–T07 +touches an offline-only code path), **183 passing under `.venv` with the +`service` extras** (the Docker-gated total moved from 146 → 170 across +T01–T05, plus 13 more non-Docker service-extras tests from this task). No +stray Docker containers left running.