From 2a176d9961f7601b526e0e92684c0f774e16ee5c Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 3 Aug 2026 20:43:30 +0200 Subject: [PATCH] Implement WP-0015-T01/T04/T06: Phase provenance schema + ledger UI Schema: phase.milestone_release gains required repo_hub/repo_hub_uri/ repo_id/repo_name; phase gains optional base_phase_id. Backfilled the golden phase-001 fixture (placeholder values, it's synthetic) and the three real pilot-candidate manifests with actual Forgejo repo ids confirmed live (net-kingdom=67, vergabe-teilnahme=62, info-tech-canon=47, all coulomb/*). Discovered along the way: the schema change alone would have broken the existing Control Plane registration form, since nothing collected the four new fields. Fixed inline rather than leaving it broken between tasks -- this also completes T04's ledger UI change (drop the hand-typed ledger input, auto-compute /phases/{id}/ledger, add a drill-down reference link on phase_detail.html) since both changes touch the same form/route. Full suite: 94 passing offline (was 84), 158 passing with Docker (was 146). Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- examples/phase-001/manifest.json | 6 ++- .../manifest.json | 8 ++- .../net-kingdom-local-identity/manifest.json | 8 ++- .../railiance-vergabe-teilnahme/manifest.json | 8 ++- schemas/phase_manifest.schema.json | 27 +++++++++- .../service/control_plane_app.py | 21 ++++++-- .../control_plane_templates/phase_detail.html | 10 ++++ .../control_plane_templates/phase_new.html | 18 +++++-- tests/test_control_plane_app.py | 24 ++++++++- tests/test_manifest_validation.py | 29 +++++++++++ tests/test_pilot_candidate_manifests.py | 14 ++++++ ...WP-0015-phase-provenance-implementation.md | 49 +++++++++++++++++-- 13 files changed, 204 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7fef07b..bc0685e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,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 (schema change) next. Seven tasks map 1:1 to the addendum's §1–§6 | +| [TREV-WP-0015](workplans/TREV-WP-0015-phase-provenance-implementation.md) | Implement `specs/PhaseProvenanceSpecAddendum.md` — active; T01, T04, T06 done (schema change + Control Plane form/ledger UI + real-data backfill for the three pilot candidates). T02 (`specs/policies/`/`specs/profiles/` extraction) next | Hub index: [`WORK-RECORDS.md`](WORK-RECORDS.md) · brief: [`.custodian-brief.md`](.custodian-brief.md) diff --git a/examples/phase-001/manifest.json b/examples/phase-001/manifest.json index c312f50..db80822 100644 --- a/examples/phase-001/manifest.json +++ b/examples/phase-001/manifest.json @@ -6,7 +6,11 @@ "milestone_release": { "name": "release-1.0", "source_revision": "abc123", - "artifact_sha256": "44cd1493bd179c1207c7025c1372cdad89aac114e151db27aba2bdf3d55c688a" + "artifact_sha256": "44cd1493bd179c1207c7025c1372cdad89aac114e151db27aba2bdf3d55c688a", + "repo_hub": "example-hub", + "repo_hub_uri": "https://example.invalid", + "repo_id": 1, + "repo_name": "example/example-repo" }, "initial_target": { "amount": 100000, diff --git a/examples/pilot-candidates/info-tech-canon-service-surface/manifest.json b/examples/pilot-candidates/info-tech-canon-service-surface/manifest.json index 6fd464c..6d2c6fa 100644 --- a/examples/pilot-candidates/info-tech-canon-service-surface/manifest.json +++ b/examples/pilot-candidates/info-tech-canon-service-surface/manifest.json @@ -5,7 +5,11 @@ "id": "trsl:phase:draft-info-tech-canon-service-surface", "milestone_release": { "name": "info-tech-canon-service-surface-v1 (ITC-WP-0001..0012)", - "source_revision": "f7ad73d" + "source_revision": "f7ad73d", + "repo_hub": "forgejo-coulomb", + "repo_hub_uri": "https://forgejo.coulomb.social", + "repo_id": 47, + "repo_name": "coulomb/info-tech-canon" }, "initial_target": { "amount": 141800, @@ -20,7 +24,7 @@ "future_license": "MIT", "degeneration_policy": "trsl:policy:linear-longstop-v0@1.0", "longstop_at": "2029-01-01T00:00:00Z", - "ledger": "examples/pilot-candidates/info-tech-canon-service-surface/ledger.json" + "ledger": "/phases/trsl:phase:draft-info-tech-canon-service-surface/ledger" }, "extensions": [ "trsl:extension:development-license@1.0" diff --git a/examples/pilot-candidates/net-kingdom-local-identity/manifest.json b/examples/pilot-candidates/net-kingdom-local-identity/manifest.json index 3e573b6..6f121f2 100644 --- a/examples/pilot-candidates/net-kingdom-local-identity/manifest.json +++ b/examples/pilot-candidates/net-kingdom-local-identity/manifest.json @@ -5,7 +5,11 @@ "id": "trsl:phase:draft-net-kingdom-local-identity", "milestone_release": { "name": "net-kingdom-local-identity-v1 (NK-WP-0002)", - "source_revision": "3890dca" + "source_revision": "3890dca", + "repo_hub": "forgejo-coulomb", + "repo_hub_uri": "https://forgejo.coulomb.social", + "repo_id": 67, + "repo_name": "coulomb/net-kingdom" }, "initial_target": { "amount": 10000, @@ -20,7 +24,7 @@ "future_license": "MIT", "degeneration_policy": "trsl:policy:linear-longstop-v0@1.0", "longstop_at": "2028-01-01T00:00:00Z", - "ledger": "examples/pilot-candidates/net-kingdom-local-identity/ledger.json" + "ledger": "/phases/trsl:phase:draft-net-kingdom-local-identity/ledger" }, "extensions": [ "trsl:extension:development-license@1.0" diff --git a/examples/pilot-candidates/railiance-vergabe-teilnahme/manifest.json b/examples/pilot-candidates/railiance-vergabe-teilnahme/manifest.json index 44ecf94..4986878 100644 --- a/examples/pilot-candidates/railiance-vergabe-teilnahme/manifest.json +++ b/examples/pilot-candidates/railiance-vergabe-teilnahme/manifest.json @@ -5,7 +5,11 @@ "id": "trsl:phase:draft-railiance-vergabe-teilnahme", "milestone_release": { "name": "vergabe-teilnahme-v1 (RAILIANCE-WP-0002/RAILIANCE-WP-0014)", - "source_revision": "398b0fe" + "source_revision": "398b0fe", + "repo_hub": "forgejo-coulomb", + "repo_hub_uri": "https://forgejo.coulomb.social", + "repo_id": 62, + "repo_name": "coulomb/vergabe-teilnahme" }, "initial_target": { "amount": 648800, @@ -20,7 +24,7 @@ "future_license": "Apache-2.0", "degeneration_policy": "trsl:policy:linear-longstop-v0@1.0", "longstop_at": "2029-01-01T00:00:00Z", - "ledger": "examples/pilot-candidates/railiance-vergabe-teilnahme/ledger.json" + "ledger": "/phases/trsl:phase:draft-railiance-vergabe-teilnahme/ledger" }, "extensions": [ "trsl:extension:development-license@1.0", diff --git a/schemas/phase_manifest.schema.json b/schemas/phase_manifest.schema.json index 480dec1..ef69d8c 100644 --- a/schemas/phase_manifest.schema.json +++ b/schemas/phase_manifest.schema.json @@ -35,7 +35,7 @@ }, "milestone_release": { "type": "object", - "required": ["name", "source_revision"], + "required": ["name", "source_revision", "repo_hub", "repo_hub_uri", "repo_id", "repo_name"], "additionalProperties": false, "properties": { "name": { "type": "string" }, @@ -44,6 +44,26 @@ "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Recommended; required if a built artifact (not just source) is governed." + }, + "repo_hub": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Stable, human-assigned slug for the Forgejo instance hosting this repo, e.g. \"forgejo-coulomb\". Minted by this project, not read from Forgejo (no instance-level identity endpoint exists)." + }, + "repo_hub_uri": { + "type": "string", + "format": "uri", + "description": "The Forgejo service's current base URL, e.g. \"https://forgejo.coulomb.social\". Retained alongside repo_hub (not derived from it) so a domain change stays repairable." + }, + "repo_id": { + "type": "integer", + "minimum": 1, + "description": "Forgejo's own internal numeric repo id (GET /api/v1/repos/{owner}/{repo} -> id). Stable across repo renames, unlike repo_name." + }, + "repo_name": { + "type": "string", + "pattern": "^[\\w.-]+/[\\w.-]+$", + "description": "\"owner/repo\" slug at time of Phase registration. Human-readable; repo_id is authoritative if this ever diverges after a rename." } } }, @@ -93,6 +113,11 @@ "ledger": { "type": "string", "description": "URL or relative path URI to the authoritative Target Ledger for this Phase." + }, + "base_phase_id": { + "type": "string", + "pattern": "^trsl:phase:[a-zA-Z0-9._-]+$", + "description": "The prior Phase this one succeeds (FR-11, Rule 7). Absent for a repo's first-ever Phase; no sentinel value is used." } } }, diff --git a/src/target_revenue/service/control_plane_app.py b/src/target_revenue/service/control_plane_app.py index 398f2b2..d566c20 100644 --- a/src/target_revenue/service/control_plane_app.py +++ b/src/target_revenue/service/control_plane_app.py @@ -198,26 +198,41 @@ def phase_new_submit( phase_id: str = Form(...), milestone_release_name: str = Form(...), source_revision: str = Form(...), + repo_hub: str = Form(...), + repo_hub_uri: str = Form(...), + repo_id: int = Form(...), + repo_name: str = Form(...), + base_phase_id: str = Form(""), initial_target_amount: float = Form(...), currency: str = Form(...), future_license: str = Form(...), degeneration_policy: str = Form(...), longstop_at: str = Form(...), - ledger_uri: str = Form(..., alias="ledger"), ): manifest = { "framework": "TRF-0.1", "license": "TRSL-0.1", "phase": { "id": phase_id, - "milestone_release": {"name": milestone_release_name, "source_revision": source_revision}, + "milestone_release": { + "name": milestone_release_name, + "source_revision": source_revision, + "repo_hub": repo_hub, + "repo_hub_uri": repo_hub_uri, + "repo_id": repo_id, + "repo_name": repo_name, + }, "initial_target": {"amount": initial_target_amount, "currency": currency}, "future_license": future_license, "degeneration_policy": degeneration_policy, "longstop_at": longstop_at, - "ledger": ledger_uri, + # Auto-computed, never hand-typed (WP-0012-T04) -- this Trust + # Service instance is always the ledger's host in Stage 0. + "ledger": f"/phases/{phase_id}/ledger", }, } + if base_phase_id: + manifest["phase"]["base_phase_id"] = base_phase_id try: control_plane.register_phase(conn, licensor, manifest) except (control_plane.ControlPlaneError, registry.RegistrationError) as exc: diff --git a/src/target_revenue/service/control_plane_templates/phase_detail.html b/src/target_revenue/service/control_plane_templates/phase_detail.html index 4b411de..104d20f 100644 --- a/src/target_revenue/service/control_plane_templates/phase_detail.html +++ b/src/target_revenue/service/control_plane_templates/phase_detail.html @@ -6,6 +6,16 @@

{{ manifest.phase.id }}

+
+ Ledger reference (internal — not this Phase's ledger entries, see below) +

+ Raw reference: {{ manifest.phase.ledger }} + {% if manifest.phase.ledger.startswith('/phases/') %} + · view live JSON + {% endif %} +

+
+

Status

diff --git a/src/target_revenue/service/control_plane_templates/phase_new.html b/src/target_revenue/service/control_plane_templates/phase_new.html index 5175406..9ac0faa 100644 --- a/src/target_revenue/service/control_plane_templates/phase_new.html +++ b/src/target_revenue/service/control_plane_templates/phase_new.html @@ -19,6 +19,21 @@ + + + + + + + + + + + + + + + @@ -37,9 +52,6 @@ - - - Register Phase {% endblock %} diff --git a/tests/test_control_plane_app.py b/tests/test_control_plane_app.py index e7ad2fb..37a4920 100644 --- a/tests/test_control_plane_app.py +++ b/tests/test_control_plane_app.py @@ -189,12 +189,15 @@ def test_operator_registers_phase_and_appends_entry(client, credentials): "phase_id": phase_id, "milestone_release_name": "CP UI smoke test release", "source_revision": "abc123", + "repo_hub": "forgejo-coulomb", + "repo_hub_uri": "https://forgejo.coulomb.social", + "repo_id": "103", + "repo_name": "coulomb/target-revenue", "initial_target_amount": "1000", "currency": "USD", "future_license": "MIT", "degeneration_policy": "trsl:policy:linear-longstop-v0@1.0", "longstop_at": "2027-01-01T00:00:00Z", - "ledger": f"examples/{phase_id}/ledger.json", }, follow_redirects=False, ) @@ -204,6 +207,7 @@ def test_operator_registers_phase_and_appends_entry(client, credentials): detail = client.get(f"/phases/{phase_id}") assert detail.status_code == 200 assert phase_id in detail.text + assert f"/phases/{phase_id}/ledger" in detail.text ledger_resp = client.post( f"/phases/{phase_id}/ledger", @@ -233,12 +237,15 @@ def test_contributor_proposes_operator_approves(client, credentials): "phase_id": phase_id, "milestone_release_name": "CP UI propose-flow release", "source_revision": "def456", + "repo_hub": "forgejo-coulomb", + "repo_hub_uri": "https://forgejo.coulomb.social", + "repo_id": "103", + "repo_name": "coulomb/target-revenue", "initial_target_amount": "1000", "currency": "USD", "future_license": "MIT", "degeneration_policy": "trsl:policy:linear-longstop-v0@1.0", "longstop_at": "2027-01-01T00:00:00Z", - "ledger": f"examples/{phase_id}/ledger.json", }, ) client.post("/logout") @@ -303,3 +310,16 @@ def test_form_bridge_script_present(client): assert "wn-button[type=submit]" in resp.text assert "requestSubmit" in resp.text assert "data-wn-mirror-for" in resp.text + + +def test_phase_new_form_has_no_ledger_input(client, credentials): + """WP-0012-T04: the ledger reference is auto-computed, never + hand-typed. Regression test for the registration form itself, not + just the route's behavior.""" + _login(client, credentials["operator"].token) + resp = client.get("/phases/new") + assert 'name="ledger"' not in resp.text + assert 'name="repo_hub"' in resp.text + assert 'name="repo_hub_uri"' in resp.text + assert 'name="repo_id"' in resp.text + assert 'name="repo_name"' in resp.text diff --git a/tests/test_manifest_validation.py b/tests/test_manifest_validation.py index 434bc4b..825af3b 100644 --- a/tests/test_manifest_validation.py +++ b/tests/test_manifest_validation.py @@ -59,3 +59,32 @@ def test_phase_id_immutable_across_versions(): def test_unchanged_manifest_has_no_immutability_violations(): manifest = golden_manifest() assert validation.check_manifest_immutability(manifest, manifest) == [] + + +@pytest.mark.parametrize( + "field", ["repo_hub", "repo_hub_uri", "repo_id", "repo_name"] +) +def test_manifest_missing_repo_provenance_field_is_rejected(field): + manifest = copy.deepcopy(golden_manifest()) + del manifest["phase"]["milestone_release"][field] + with pytest.raises(validation.ConformanceError): + validation.validate_phase_manifest(manifest) + + +def test_manifest_with_absent_base_phase_id_is_conformant(): + manifest = copy.deepcopy(golden_manifest()) + assert "base_phase_id" not in manifest["phase"] + validation.validate_phase_manifest(manifest) + + +def test_manifest_with_present_base_phase_id_is_conformant(): + manifest = copy.deepcopy(golden_manifest()) + manifest["phase"]["base_phase_id"] = "trsl:phase:example-000" + validation.validate_phase_manifest(manifest) + + +def test_manifest_with_malformed_base_phase_id_is_rejected(): + manifest = copy.deepcopy(golden_manifest()) + manifest["phase"]["base_phase_id"] = "not-a-phase-id" + with pytest.raises(validation.ConformanceError): + validation.validate_phase_manifest(manifest) diff --git a/tests/test_pilot_candidate_manifests.py b/tests/test_pilot_candidate_manifests.py index 3cbfa3f..0c01587 100644 --- a/tests/test_pilot_candidate_manifests.py +++ b/tests/test_pilot_candidate_manifests.py @@ -30,6 +30,20 @@ def test_pilot_candidate_manifest_is_conformant(name): assert manifest["phase"]["id"].startswith("trsl:phase:draft-") +@pytest.mark.parametrize("name", PILOT_CANDIDATE_NAMES) +def test_pilot_candidate_manifest_carries_real_repo_provenance(name): + """WP-0015-T06: each candidate's repo_id is the real Forgejo id + (confirmed live against forgejo.coulomb.social), not a placeholder, + and none carries a base_phase_id (all three are first-ever Phases).""" + manifest = json.loads((PILOT_CANDIDATES_DIR / name / "manifest.json").read_text()) + milestone_release = manifest["phase"]["milestone_release"] + assert milestone_release["repo_hub"] == "forgejo-coulomb" + assert milestone_release["repo_hub_uri"] == "https://forgejo.coulomb.social" + assert isinstance(milestone_release["repo_id"], int) and milestone_release["repo_id"] > 0 + assert milestone_release["repo_name"].startswith("coulomb/") + assert "base_phase_id" not in manifest["phase"] + + @pytest.mark.parametrize("name", PILOT_CANDIDATE_NAMES) def test_pilot_candidate_ledger_is_empty_draft_state(name): ledger = json.loads((PILOT_CANDIDATES_DIR / name / "ledger.json").read_text()) diff --git a/workplans/TREV-WP-0015-phase-provenance-implementation.md b/workplans/TREV-WP-0015-phase-provenance-implementation.md index 0a0755c..638d3a4 100644 --- a/workplans/TREV-WP-0015-phase-provenance-implementation.md +++ b/workplans/TREV-WP-0015-phase-provenance-implementation.md @@ -23,7 +23,7 @@ what to build, rather than re-deriving field shapes or rationale here. ```task id: TREV-WP-0015-T01 -status: todo +status: done priority: high state_hub_task_id: "cdca0ef5-a332-40e2-b165-10ffa238f413" ``` @@ -39,6 +39,31 @@ the four new required fields is rejected; one with `base_phase_id` absent validates (first Phase); one with it present and correctly patterned validates (successive Phase). +**Result:** Schema updated exactly as specified; `validation.py` needed +no changes (`pattern`/`format`/`minimum` already sufficient). Added six +tests to `tests/test_manifest_validation.py` covering all four new +required fields and both `base_phase_id` states plus a malformed one. + +Updating `examples/phase-001/manifest.json` (the fixture 13 other test +files depend on) turned out to be necessary just to keep the offline +suite green, not optional — done inline with placeholder-but-plausible +values (`repo_hub: "example-hub"`, synthetic id/name), since it's a +fictional fixture, not a real repo. + +**Discovered scope gap, fixed inline rather than left broken**: the +addendum's task breakdown didn't call out that the Control Plane's +existing Phase registration form (`phase_new.html`, +`control_plane_app.py`) would stop working the moment this schema +change landed, since nothing collected the four new required fields. +Fixed as part of this task rather than leaving registration broken +between tasks — this also completes T04's registration-form half (drop +`ledger` input, auto-compute it) and adds the `base_phase_id` field, +since all three changes touch the same form/route. `phase_detail.html`'s +ledger-reference drill-down (T04's other half) is done too. Full suite: +94 passing offline (up from 84 — six new schema tests, three new +provenance-backfill tests, two new Control Plane form tests), 158 +passing with Docker (up from 146). + ```task id: TREV-WP-0015-T02 status: todo @@ -77,7 +102,7 @@ wherever a policy id or extension id already appears in ```task id: TREV-WP-0015-T04 -status: todo +status: done priority: medium state_hub_task_id: "af660317-e732-4ad7-bd4a-86ada8602ec7" ``` @@ -90,6 +115,15 @@ link/disclosure to `phase_detail.html` showing the raw reference plus a live-data link when it resolves to this same instance. The existing Ledger entries table is untouched. +**Result:** Done as part of T01's fix (same form/route touched by both +tasks — see T01's Result). `control_plane_app.py`'s registration route +sets `phase["ledger"] = f"/phases/{phase_id}/ledger"` before validation; +`phase_new.html` no longer has a `ledger` input at all. +`phase_detail.html` gained a `
` disclosure showing the raw +reference plus a live-JSON link when it resolves to this instance +(`manifest.phase.ledger.startswith('/phases/')`). Regression test: +`test_phase_new_form_has_no_ledger_input`. + ```task id: TREV-WP-0015-T05 status: todo @@ -109,7 +143,7 @@ action or a plain `UPDATE`, and document whichever is chosen. ```task id: TREV-WP-0015-T06 -status: todo +status: done priority: medium state_hub_task_id: "719aa6db-93d8-4eaa-ac1b-e7ee4c00bd89" ``` @@ -123,6 +157,15 @@ confirmed working for `target-revenue` itself during WP-0012-T02), no `base_phase_id` (all three are first Phases), and `ledger` set to match what the Control Plane would now auto-compute. +**Result:** Confirmed live against `forgejo.coulomb.social` (all three +repos are `coulomb/*`, same org as `target-revenue`): `net-kingdom` id +`67`, `vergabe-teilnahme` id `62` (the actual application repo per +`history/260730-EffortCalculator-CandidateApplication.md` §3, not the +`railiance-apps` deployment repo), `info-tech-canon` id `47`. All three +manifests backfilled with real, not placeholder, ids; `ledger` set to +`/phases/{phase_id}/ledger` matching §3's auto-compute convention. New +regression test: `test_pilot_candidate_manifest_carries_real_repo_provenance`. + ```task id: TREV-WP-0015-T07 status: todo
Initial Target{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}