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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-03 20:43:30 +02:00
parent 11800c7da1
commit 2a176d9961
13 changed files with 204 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

@ -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."
}
}
},

View file

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

View file

@ -6,6 +6,16 @@
</wn-page-header>
<p><wn-tag>{{ manifest.phase.id }}</wn-tag></p>
<details style="margin-bottom:1rem;color:#888;font-size:0.9rem;">
<summary style="cursor:pointer;">Ledger reference (internal — not this Phase's ledger entries, see below)</summary>
<p>
Raw reference: <code>{{ manifest.phase.ledger }}</code>
{% if manifest.phase.ledger.startswith('/phases/') %}
&middot; <a href="{{ manifest.phase.ledger }}">view live JSON</a>
{% endif %}
</p>
</details>
<h3>Status</h3>
<table class="wn-plain">
<tr><td>Initial Target</td><td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td></tr>

View file

@ -19,6 +19,21 @@
<wn-field-row label="Source revision">
<wn-input name="source_revision" required></wn-input>
</wn-field-row>
<wn-field-row label="Repo hub (stable Forgejo instance slug)">
<wn-input name="repo_hub" value="forgejo-coulomb" required></wn-input>
</wn-field-row>
<wn-field-row label="Repo hub URI">
<wn-input name="repo_hub_uri" value="https://forgejo.coulomb.social" required></wn-input>
</wn-field-row>
<wn-field-row label="Repo id (Forgejo's own numeric id)">
<wn-input type="number" name="repo_id" required></wn-input>
</wn-field-row>
<wn-field-row label="Repo name (owner/repo)">
<wn-input name="repo_name" required></wn-input>
</wn-field-row>
<wn-field-row label="Base Phase id (optional — only for a successive Phase)">
<wn-input name="base_phase_id"></wn-input>
</wn-field-row>
<wn-field-row label="Initial Target amount">
<wn-input type="number" step="0.01" name="initial_target_amount" required></wn-input>
</wn-field-row>
@ -37,9 +52,6 @@
<wn-field-row label="Longstop date (ISO 8601)">
<wn-input name="longstop_at" required></wn-input>
</wn-field-row>
<wn-field-row label="Ledger URI (relative path or URL)">
<wn-input name="ledger" required></wn-input>
</wn-field-row>
<wn-button type="submit" variant="primary">Register Phase</wn-button>
</form>
{% endblock %}

View file

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

View file

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

View file

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

View file

@ -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 `<details>` 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