Release governance materialization

This module owns the typed release governance document and the canonical changelog state. CHANGELOG.md is a whole-file materialization owned by Govenv: on an ordinary authorized revision its governed Unreleased state is derived from the latest published release boundary to the causal semantic revision and contains both the typed governance delta and the conventional release notes for non-derived commits in that same interval. Derived materialization commits resolve through single-parent Git edges until the first non-derived causal revision and never become release-note content, so provenance survives rebase rewriting without making the changelog self-referential. On a Release Please candidate that exact Unreleased payload is frozen beneath an empty Unreleased heading under the candidate SemVer; only candidate metadata such as version, heading, and freeze boundary may be added. The freeze records its published-base tag and full authorizing revision, so the exact approved candidate remains reconstructible during the interval after human merge and before tag publication; once the tag exists, the same frozen entry becomes immutable release history. Release Please remains the observer for SemVer and may independently analyze Conventional Commits, but its rendered notes are verification input rather than changelog authority. Historical release entries are observed only from immutable revision-addressable release boundaries and are carried as reconstruction inputs, never trusted from the surviving mutable changelog.

The pull-request and GitHub Release projections share the same typed release entry. A frozen candidate is not ready for human authorization until its exact final head has passed the authoritative repository check under candidate-safe, read-only execution; the privileged Release job may author and read back the candidate but must not execute candidate repository state. ReleaseGovernance/placement-counterexample.md preserves the PR #3 placement regression, ReleaseGovernance/history-preservation-counterexample.md preserves the observed loss of the 0.2.0 governance history, ReleaseGovernance/push-auth-counterexample.md preserves the Stage B release-branch mutation regression from run #35, ReleaseGovernance/rebase-provenance-counterexample.md preserves the stale-SHA materialization regression from run #39, ReleaseGovernance/candidate-validation-counterexample.md preserves the missing post-mutation candidate check observed on PR #15 after run #43, and ReleaseGovernance/unreleased-notes-counterexample.md preserves the 0.2.3 regression where c41881f was inside the governed range but absent from the materialized Unreleased notes, and ReleaseGovernance/published-renderer-counterexample.md preserves run 35256420319, where the published-release read-back expected the legacy GitHub renderer instead of the portable canonical release entry. ReleaseGovernance/published-body-readback-counterexample.md preserves run 35512667953, where the observation path added a terminal newline to an otherwise exact published release body. Read-back evidence must preserve the observed external state rather than introduce presentation bytes of its own. Candidate validation must reject recurrence before human approval may establish release authority.

{-# OPTIONS --safe #-}

module Govenv.Materialization.ReleaseGovernance where

open import Agda.Builtin.Bool using (Bool; true; false)
open import Agda.Builtin.List using (List; []; _∷_)
open import Agda.Builtin.Maybe using (Maybe; just; nothing)
open import Agda.Builtin.Nat using (Nat; zero; suc; _==_)
open import Agda.Builtin.String using (String; primStringAppend; primStringEquality)
open import Govenv.Kernel.Identifier using
  ( GovernanceRef; PhaseId; SomeGovernanceId; SomePhaseId
  ; descriptionOf; indexOf; someIdentifier )
open import Govenv.Kernel.Release
open import Govenv.Kernel.Roadmap using
  ( Membership; PhaseNode; PhaseState; Roadmap
  ; complete; membership; phaseNode; progressing; lookupGovernanceRef )
open import Govenv.Materialization

data ImpactKind : Set where
  completedImpact advancedImpact introducedImpact : ImpactKind
  cancelledImpact supersededImpact : ImpactKind

record ImpactGroup : Set where
  constructor impactGroup
  field
    kind : ImpactKind
    label : String
    items : List ItemImpact
    count : Nat

record PhaseChange : Set where
  constructor phaseChange
  field
    previousPhase : SomePhaseId
    currentPhase : SomePhaseId

record PropositionChange : Set where
  constructor propositionChange
  field
    previousProposition : String
    currentProposition : String

data SupersessionDelta : Set where
  resolvedSupersession :
    SomeGovernanceId →
    SomeGovernanceId →
    Maybe PhaseChange →
    Maybe PropositionChange →
    SupersessionDelta
  unresolvedSupersession :
    SomeGovernanceId → GovernanceRef → SupersessionDelta

record ReleaseDocument : Set where
  constructor releaseDocument
  field
    heading : String
    phaseLabel : String
    itemsLabel : String
    completedGroup : ImpactGroup
    advancedGroup : ImpactGroup
    introducedGroup : ImpactGroup
    cancelledGroup : ImpactGroup
    supersededGroup : ImpactGroup
    supersessions : List SupersessionDelta
    phase : PhaseProgress
    noImpactLabel : String
    introducedPhaseLabel : String
    unchangedPhaseLabel : String
    roadmapCompleteLabel : String
    footer : String
    baseRevision : String
    headRevision : String

data ReleaseNoteKind : Set where
  featureNote fixNote governanceNote documentationNote refactorNote : ReleaseNoteKind
  performanceNote testNote buildNote ciNote choreNote : ReleaseNoteKind

record ConventionalCommitObservation : Set where
  constructor conventionalCommit
  field
    commitType : String
    commitScope : String
    commitDescription : String
    commitRevision : String
    commitDerived : Bool

record ReleaseNote : Set where
  constructor releaseNote
  field
    noteKind : ReleaseNoteKind
    noteScope : String
    noteDescription : String
    noteRevision : String
    noteRevisionUrl : String

record ReleaseNoteGroup : Set where
  constructor releaseNoteGroup
  field
    noteGroupLabel : String
    noteGroupItems : List ReleaseNote

record ReleaseEntry : Set where
  constructor releaseEntry
  field
    entryDocument : ReleaseDocument
    entryNoteGroups : List ReleaseNoteGroup

private
  _++_ : String → String → String
  _++_ = primStringAppend

  releaseNoteKindIndex : ReleaseNoteKind → Nat
  releaseNoteKindIndex featureNote = 0
  releaseNoteKindIndex fixNote = 1
  releaseNoteKindIndex governanceNote = 2
  releaseNoteKindIndex documentationNote = 3
  releaseNoteKindIndex refactorNote = 4
  releaseNoteKindIndex performanceNote = 5
  releaseNoteKindIndex testNote = 6
  releaseNoteKindIndex buildNote = 7
  releaseNoteKindIndex ciNote = 8
  releaseNoteKindIndex choreNote = 9

  notesOfKind : ReleaseNoteKind → List ReleaseNote → List ReleaseNote
  notesOfKind kind [] = []
  notesOfKind kind (note ∷ rest)
    with releaseNoteKindIndex kind == releaseNoteKindIndex (ReleaseNote.noteKind note)
  ... | true = note ∷ notesOfKind kind rest
  ... | false = notesOfKind kind rest

  matchReleaseNoteKind :
    String → String → ReleaseNoteKind → Maybe ReleaseNoteKind → Maybe ReleaseNoteKind
  matchReleaseNoteKind observed expected kind fallback
    with primStringEquality observed expected
  ... | true = just kind
  ... | false = fallback

  classifyReleaseNoteKind : String → Maybe ReleaseNoteKind
  classifyReleaseNoteKind value =
    matchReleaseNoteKind value "feat" featureNote
    (matchReleaseNoteKind value "fix" fixNote
    (matchReleaseNoteKind value "gov" governanceNote
    (matchReleaseNoteKind value "docs" documentationNote
    (matchReleaseNoteKind value "refactor" refactorNote
    (matchReleaseNoteKind value "perf" performanceNote
    (matchReleaseNoteKind value "test" testNote
    (matchReleaseNoteKind value "build" buildNote
    (matchReleaseNoteKind value "ci" ciNote
    (matchReleaseNoteKind value "chore" choreNote nothing)))))))))

  semanticReleaseNotes : List ConventionalCommitObservation → List ReleaseNote
  semanticReleaseNotes [] = []
  semanticReleaseNotes (commit ∷ rest)
    with ConventionalCommitObservation.commitDerived commit
       | classifyReleaseNoteKind (ConventionalCommitObservation.commitType commit)
  ... | true | _ = semanticReleaseNotes rest
  ... | false | nothing = semanticReleaseNotes rest
  ... | false | just kind =
    releaseNote
      kind
      (ConventionalCommitObservation.commitScope commit)
      (ConventionalCommitObservation.commitDescription commit)
      (ConventionalCommitObservation.commitRevision commit)
      ("https://github.com/klarkc/govenv/commit/" ++ ConventionalCommitObservation.commitRevision commit) ∷
    semanticReleaseNotes rest

releaseNotes : List ConventionalCommitObservation → List ReleaseNoteGroup
releaseNotes observations =
  releaseNoteGroup "Features" (notesOfKind featureNote notes) ∷
  releaseNoteGroup "Bug Fixes" (notesOfKind fixNote notes) ∷
  releaseNoteGroup "Governance" (notesOfKind governanceNote notes) ∷
  releaseNoteGroup "Documentation" (notesOfKind documentationNote notes) ∷
  releaseNoteGroup "Code Refactoring" (notesOfKind refactorNote notes) ∷
  releaseNoteGroup "Performance" (notesOfKind performanceNote notes) ∷
  releaseNoteGroup "Tests" (notesOfKind testNote notes) ∷
  releaseNoteGroup "Build System" (notesOfKind buildNote notes) ∷
  releaseNoteGroup "Continuous Integration" (notesOfKind ciNote notes) ∷
  releaseNoteGroup "Miscellaneous" (notesOfKind choreNote notes) ∷ []
  where
  notes : List ReleaseNote
  notes = semanticReleaseNotes observations

record CandidateBoundary : Set where
  constructor candidateBoundary
  field
    candidateVersion : String
    candidateHeading : String
    candidateBaseRef : String
    candidateAuthorizedRevision : String

data ChangelogCurrent : Set where
  emptyUnreleased : ChangelogCurrent
  unreleased : ReleaseEntry → ChangelogCurrent
  frozenCandidate : CandidateBoundary → ReleaseEntry → ChangelogCurrent

record ChangelogDocument : Set where
  constructor changelogDocument
  field
    current : ChangelogCurrent
    historicalEntries : List String

private
  countItems : List ItemImpact → Nat
  countItems [] = zero
  countItems (item ∷ rest) = suc (countItems rest)

  completedItems : List ItemImpact → List ItemImpact
  completedItems [] = []
  completedItems (item@(impact itemId state completed) ∷ rest) =
    item ∷ completedItems rest
  completedItems (impact itemId state advanced ∷ rest) = completedItems rest
  completedItems (impact itemId state introduced ∷ rest) = completedItems rest
  completedItems (impact itemId state cancelledProgress ∷ rest) = completedItems rest
  completedItems (impact itemId state (supersededProgress replacement) ∷ rest) =
    completedItems rest

  advancedItems : List ItemImpact → List ItemImpact
  advancedItems [] = []
  advancedItems (impact itemId state completed ∷ rest) = advancedItems rest
  advancedItems (item@(impact itemId state advanced) ∷ rest) =
    item ∷ advancedItems rest
  advancedItems (impact itemId state introduced ∷ rest) = advancedItems rest
  advancedItems (impact itemId state cancelledProgress ∷ rest) = advancedItems rest
  advancedItems (impact itemId state (supersededProgress replacement) ∷ rest) =
    advancedItems rest

  introducedItems : List ItemImpact → List ItemImpact
  introducedItems [] = []
  introducedItems (impact itemId state completed ∷ rest) = introducedItems rest
  introducedItems (impact itemId state advanced ∷ rest) = introducedItems rest
  introducedItems (item@(impact itemId state introduced) ∷ rest) =
    item ∷ introducedItems rest
  introducedItems (impact itemId state cancelledProgress ∷ rest) = introducedItems rest
  introducedItems (impact itemId state (supersededProgress replacement) ∷ rest) =
    introducedItems rest

  cancelledItems : List ItemImpact → List ItemImpact
  cancelledItems [] = []
  cancelledItems (impact itemId state completed ∷ rest) = cancelledItems rest
  cancelledItems (impact itemId state advanced ∷ rest) = cancelledItems rest
  cancelledItems (impact itemId state introduced ∷ rest) = cancelledItems rest
  cancelledItems (item@(impact itemId state cancelledProgress) ∷ rest) =
    item ∷ cancelledItems rest
  cancelledItems (impact itemId state (supersededProgress replacement) ∷ rest) =
    cancelledItems rest

  supersededItems : List ItemImpact → List ItemImpact
  supersededItems [] = []
  supersededItems (impact itemId state completed ∷ rest) = supersededItems rest
  supersededItems (impact itemId state advanced ∷ rest) = supersededItems rest
  supersededItems (impact itemId state introduced ∷ rest) = supersededItems rest
  supersededItems (impact itemId state cancelledProgress ∷ rest) = supersededItems rest
  supersededItems
    (item@(impact itemId state (supersededProgress replacement)) ∷ rest) =
      item ∷ supersededItems rest

  group : ImpactKind → String → List ItemImpact → ImpactGroup
  group kind label items = impactGroup kind label items (countItems items)

  lookupMembershipPhase :
    {phaseIdx : Nat} {phaseDescription : String} →
    (phase : PhaseId phaseIdx phaseDescription) →
    Nat → List (Membership phase) → Maybe SomePhaseId
  lookupMembershipPhase phase idx [] = nothing
  lookupMembershipPhase phase idx
    (membership governanceId state relation ∷ rest)
    with idx == indexOf governanceId
  ... | true = just (someIdentifier phase)
  ... | false = lookupMembershipPhase phase idx rest

  lookupPhaseOwner :
    {state : PhaseState} → Nat → PhaseNode state → Maybe SomePhaseId
  lookupPhaseOwner idx (phaseNode phaseId items) =
    lookupMembershipPhase phaseId idx items

  lookupPhaseOwners :
    {state : PhaseState} → Nat → List (PhaseNode state) → Maybe SomePhaseId
  lookupPhaseOwners idx [] = nothing
  lookupPhaseOwners idx (phase ∷ rest) with lookupPhaseOwner idx phase
  ... | just phaseId = just phaseId
  ... | nothing = lookupPhaseOwners idx rest

  lookupGovernancePhase : Nat → Roadmap → Maybe SomePhaseId
  lookupGovernancePhase idx (progressing finished current futures)
    with lookupPhaseOwners idx finished
  ... | just phaseId = just phaseId
  ... | nothing with lookupPhaseOwner idx current
  ...   | just phaseId = just phaseId
  ...   | nothing = lookupPhaseOwners idx futures
  lookupGovernancePhase idx (complete finished) =
    lookupPhaseOwners idx finished

  governanceIndex : SomeGovernanceId → Nat
  governanceIndex (someIdentifier governanceId) = indexOf governanceId

  governanceDescription : SomeGovernanceId → String
  governanceDescription (someIdentifier governanceId) = descriptionOf governanceId

  samePhase : SomePhaseId → SomePhaseId → Bool
  samePhase (someIdentifier previous) (someIdentifier current) =
    indexOf previous == indexOf current

  phaseChangeBetween : SomePhaseId → SomePhaseId → Maybe PhaseChange
  phaseChangeBetween previous current with samePhase previous current
  ... | true = nothing
  ... | false = just (phaseChange previous current)

  phaseChangeFor :
    Roadmap → SomeGovernanceId → SomeGovernanceId → Maybe PhaseChange
  phaseChangeFor roadmap previous current
    with lookupGovernancePhase (governanceIndex previous) roadmap
       | lookupGovernancePhase (governanceIndex current) roadmap
  ... | just previousOwner | just currentOwner =
    phaseChangeBetween previousOwner currentOwner
  ... | _ | _ = nothing

  propositionChangeFor :
    SomeGovernanceId → SomeGovernanceId → Maybe PropositionChange
  propositionChangeFor previous current
    with primStringEquality
      (governanceDescription previous)
      (governanceDescription current)
  ... | true = nothing
  ... | false = just
      (propositionChange
        (governanceDescription previous)
        (governanceDescription current))

supersessionDeltas : Roadmap → List ItemImpact → List SupersessionDelta
supersessionDeltas roadmap [] = []
supersessionDeltas roadmap
  (impact previous state (supersededProgress replacement) ∷ rest)
  with lookupGovernanceRef replacement roadmap
... | nothing =
  unresolvedSupersession previous replacement ∷ supersessionDeltas roadmap rest
... | just current =
  resolvedSupersession
    previous
    current
    (phaseChangeFor roadmap previous current)
    (propositionChangeFor previous current) ∷
  supersessionDeltas roadmap rest
supersessionDeltas roadmap (impact itemId state progress ∷ rest) =
  supersessionDeltas roadmap rest

document : String → String → Roadmap → GovernanceDelta → ReleaseDocument
document baseRevision headRevision roadmap (governanceDeltaValue impacts phase) =
  releaseDocument
    "Governance impact"
    "Phase"
    "Items"
    (group completedImpact "completed" (completedItems impacts))
    (group advancedImpact "advanced" (advancedItems impacts))
    (group introducedImpact "introduced" (introducedItems impacts))
    (group cancelledImpact "cancelled" (cancelledItems impacts))
    (group supersededImpact "superseded" (supersededItems impacts))
    (supersessionDeltas roadmap impacts)
    phase
    "no roadmap item impact"
    "phase governance introduced"
    "unchanged"
    "roadmap complete"
    "Derived from immutable typed roadmap snapshots and governed `Refs: GV…` commit metadata. SemVer remains independent."
    baseRevision
    headRevision

pullRequestBody :
  Nat → String → String → String → Roadmap → GovernanceDelta → Materialization ReleaseDocument
pullRequestBody number releaseVersion baseRevision headRevision roadmap delta = materialized
  (githubPullRequestBodySection number releaseGovernanceImpact
    (afterReleaseHeadingInBody releaseVersion))
  automatic
  repository
  authorizedOnly
  pullRequestBodySectionEquality
  (document baseRevision headRevision roadmap delta)

changelog : ChangelogCurrent → List String → Materialization ChangelogDocument
changelog current history = materialized
  (repositoryFile "CHANGELOG.md")
  automatic
  repository
  authorizedOnly
  trackedEquality
  (changelogDocument current history)

githubRelease :
  String → String → String → Roadmap → GovernanceDelta → Materialization ReleaseDocument
githubRelease tag baseRevision headRevision roadmap delta = materialized
  (githubReleaseBodySection tag releaseGovernanceImpactInRelease replaceCarriedChangelogSection)
  automatic
  repository
  authorizedOnly
  githubReleaseBodySectionEquality
  (document baseRevision headRevision roadmap delta)