Complete WP-0015: forgejo_hubs migration + closeout (T05/T07)

migrations/0007_forgejo_hubs.sql: table forgejo_hubs, auto-populated
via a BEFORE INSERT trigger on phase_manifests that reads repo_hub/
repo_hub_uri straight out of the manifest JSONB (no top-level columns
needed). ON CONFLICT DO NOTHING -- a hub already seen is left alone;
correcting a URI is a SECURITY DEFINER governance action
(correct_forgejo_hub_uri), not a plain UPDATE, matching every other
governance-action pattern in this project. Thin Python wrappers added
to registry.py.

tests/test_forgejo_hubs.py (6 Docker-gated tests) and
tests/test_reference_docs.py (13 tests, no Docker needed -- smoke-tests
every real specs/policies/specs/profiles/ file, not just the two
exercised incidentally by T03's Control Plane tests).

All seven WP-0015 tasks done; workplan marked finished. Final suite:
94 passing offline, 183 passing under the service extras venv. No
stray Docker containers left running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-03 23:57:46 +02:00
parent eaea89e125
commit de308f8947
6 changed files with 419 additions and 5 deletions

View file

@ -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 (T02T04) 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-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 (T02T04) 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; T01T03 `wait` on WP-0012-T03's policy-spec-file decision. Nothing currently computes or writes `remission-credit` ledger entries | | [TREV-WP-0013](workplans/TREV-WP-0013-remission-credit-automation.md) | Remission Credit automation (degeneration policy execution) — active; T01T03 `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-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; T01T04, 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) Hub index: [`WORK-RECORDS.md`](WORK-RECORDS.md) · brief: [`.custodian-brief.md`](.custodian-brief.md)

View file

@ -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;

View file

@ -29,7 +29,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from psycopg import Connection from psycopg import Connection
from psycopg.errors import UniqueViolation from psycopg.errors import RaiseException, UniqueViolation
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from . import validation from . import validation
@ -296,3 +296,45 @@ def promote_extension_canonical(
"SELECT set_extension_status(%s, %s, %s, %s)", "SELECT set_extension_status(%s, %s, %s, %s)",
(extension_id, version, "canonical", approved_by), (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

177
tests/test_forgejo_hubs.py Normal file
View file

@ -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()

View file

@ -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 "<h1>" in html or "<h2>" 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 "<h1>" 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"

View file

@ -4,7 +4,7 @@ type: workplan
title: "Implement Phase provenance, spec-file, and ledger UI changes" title: "Implement Phase provenance, spec-file, and ledger UI changes"
domain: infotech domain: infotech
repo: target-revenue repo: target-revenue
status: active status: finished
owner: claude owner: claude
topic_slug: infotech topic_slug: infotech
created: "2026-08-03" created: "2026-08-03"
@ -173,7 +173,7 @@ reference plus a live-JSON link when it resolves to this instance
```task ```task
id: TREV-WP-0015-T05 id: TREV-WP-0015-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "816f1ac2-da40-4550-8d6c-64a1a335a660" 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 correcting a hub's URI after the fact is a `SECURITY DEFINER` governance
action or a plain `UPDATE`, and document whichever is chosen. 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 ```task
id: TREV-WP-0015-T06 id: TREV-WP-0015-T06
status: done status: done
@ -215,7 +240,7 @@ regression test: `test_pilot_candidate_manifest_carries_real_repo_provenance`.
```task ```task
id: TREV-WP-0015-T07 id: TREV-WP-0015-T07
status: todo status: done
priority: high priority: high
state_hub_task_id: "39b34438-a1b1-49da-a88a-f3bec926aa89" 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 Result sections; run the full offline + Docker-gated suite; fence-count
check before committing any workplan edit, per this project's standing check before committing any workplan edit, per this project's standing
practice. 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 T02T07
touches an offline-only code path), **183 passing under `.venv` with the
`service` extras** (the Docker-gated total moved from 146 → 170 across
T01T05, plus 13 more non-Docker service-extras tests from this task). No
stray Docker containers left running.