feat(P8): IHF Phase 8 complete — Federated Hub Maturity

Implements the final phase of the IHF v0.1 specification:

- WidgetOwnership: delegated ownership registry (local/delegated/global),
  append-only audit artefacts, ownership badge on widget show page
- HubRoutingRule + RoutingEngine: priority-ordered inter-hub routing engine;
  null-inclusive category/widget-type matching; RouteNowAction for manual
  re-evaluation; RoutedCandidates view per hub
- FederatedPolicyOverlay: draft → active → retired lifecycle; activated
  overlays are immutable (same pattern as Phase 6 contracts); policy
  compliance dashboard with decision coverage metrics
- StewardshipRole: named governance roles per hub; point-in-time revocation
  pattern; hub and ops-board integration
- ArchiveRecord + is_archived: soft-delete on widgets; lineage inspector
  traces full traceability chain (Widget → Events → Annotations → Candidates
  → Requirements → Decisions → Deployments → Signals + ArchiveRecord)
- FederatedGovernanceDashboard: 5-panel autoRefresh org-wide governance view
  (ownership coverage, routing activity, policy compliance, stewardship
  coverage, archive activity)

Schema: widget_ownerships, hub_routing_rules, federated_policy_overlays,
stewardship_roles, archive_records; ALTER widgets ADD is_archived;
ALTER requirement_candidates ADD routed_to_hub_id

Migration: 1743638400-ihf-phase8-federated-hub-maturity.sql
Tests: Phase 8 integration tests appended to Test/Integration.hs
Docs: docs/phase8-summary.md; SCOPE.md updated to Phase 8 complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 22:53:01 +00:00
parent 63fb0e8277
commit 9265ca2d9c
37 changed files with 2400 additions and 12 deletions

View File

@@ -0,0 +1,56 @@
module Web.Controller.ArchiveRecords where
import Web.Types
import Web.View.ArchiveRecords.Index
import Web.View.ArchiveRecords.Show
import Web.View.ArchiveRecords.LineageInspector
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller ArchiveRecordsController where
beforeAction = ensureIsUser
action ArchiveRecordsAction = do
records <- query @ArchiveRecord |> orderByDesc #archivedAt |> fetch
render IndexView { records }
action ShowArchiveRecordAction { archiveRecordId } = do
record <- fetch archiveRecordId
render ShowView { record }
action ArchiveWidgetAction { widgetId } = do
widget <- fetch widgetId
now <- getCurrentTime
widget |> set #isArchived True |> updateRecord
newRecord @ArchiveRecord
|> set #subjectType "Widget"
|> set #subjectId (coerce widgetId)
|> set #archivedAt now
|> set #reason "Archived via UI"
|> set #archivedBy "operator"
|> createRecord
setSuccessMessage "Widget archived"
redirectTo ShowWidgetAction { widgetId }
action LineageInspectorAction { widgetId } = do
widget <- fetch widgetId
events <- sqlQuery "SELECT * FROM interaction_events WHERE widget_id = ? ORDER BY occurred_at DESC LIMIT 50" (Only widgetId)
annotations <- query @Annotation |> filterWhere (#widgetId, widgetId) |> orderByDesc #createdAt |> fetch
candidates <- query @RequirementCandidate |> filterWhere (#sourceWidgetId, widgetId) |> fetch
let candidateIds = map (.id) candidates
acceptedIds = map (.id) (filter (\c -> c.status == "accepted") candidates)
requirements <- query @Requirement |> filterWhereIn (#sourceCandidateId, acceptedIds) |> fetch
let reqIds = map (.id) requirements
decisions <- query @DecisionRecord |> filterWhereIn (#requirementId, map Just reqIds) |> fetch
let decisionIds = map (.id) decisions
deployments <- query @DeploymentRecord |> filterWhereIn (#decisionId, decisionIds) |> fetch
signals <- query @OutcomeSignal |> filterWhere (#widgetId, widgetId) |> fetch
archiveEntry <- fetchOneOrNothing (Id (coerce widgetId) :: Id ArchiveRecord)
-- archiveEntry lookup by subject_id
mArchive <- do
rs <- sqlQuery "SELECT * FROM archive_records WHERE subject_id = ? AND subject_type = 'Widget' ORDER BY archived_at DESC LIMIT 1" (Only widgetId)
pure (listToMaybe (rs :: [ArchiveRecord]))
render LineageInspectorView
{ widget, events, annotations, candidates, requirements
, decisions, deployments, signals, mArchive }

View File

@@ -0,0 +1,43 @@
module Web.Controller.FederatedGovernance where
import Web.Types
import Web.View.FederatedGovernance.Dashboard
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Time.Clock (addUTCTime, getCurrentTime)
import Data.Aeson (decode)
import qualified Data.ByteString.Lazy as LBS
import Data.Text.Encoding (encodeUtf8)
instance Controller FederatedGovernanceController where
beforeAction = ensureIsUser
action FederatedGovernanceDashboardAction = autoRefresh do
hubs <- query @Hub |> orderByAsc #name |> fetch
widgets <- query @Widget |> fetch
ownerships <- query @WidgetOwnership |> fetch
rules <- query @HubRoutingRule |> filterWhere (#status, "active") |> fetch
now <- getCurrentTime
let thirtyDaysAgo = addUTCTime (negate $ 30 * 86400) now
ninetyDaysAgo = addUTCTime (negate $ 90 * 86400) now
-- Candidates routed cross-hub in last 30 days
routedCandidates <- sqlQuery
"SELECT * FROM requirement_candidates WHERE routed_to_hub_id IS NOT NULL AND created_at >= ?"
(Only thirtyDaysAgo)
-- Active overlays
overlays <- query @FederatedPolicyOverlay |> filterWhere (#status, "active") |> fetch
-- All decisions for policy compliance check
allDecisions <- query @DecisionRecord |> fetch
allPolicies <- query @PolicyReference |> fetch
-- Active stewardship roles
stewards <- query @StewardshipRole
|> filterWhereSql (#revokedAt, "IS NULL")
|> fetch
-- Archive records in last 90 days
recentArchives <- sqlQuery
"SELECT * FROM archive_records WHERE archived_at >= ?"
(Only ninetyDaysAgo)
render FederatedGovernanceDashboardView
{ hubs, widgets, ownerships, rules, routedCandidates
, overlays, allDecisions, allPolicies, stewards, recentArchives }

View File

@@ -0,0 +1,91 @@
module Web.Controller.FederatedPolicyOverlays where
import Web.Types
import Web.View.FederatedPolicyOverlays.Index
import Web.View.FederatedPolicyOverlays.Show
import Web.View.FederatedPolicyOverlays.New
import Web.View.FederatedPolicyOverlays.Edit
import Web.View.FederatedPolicyOverlays.PolicyComplianceDashboard
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller FederatedPolicyOverlaysController where
beforeAction = ensureIsUser
action FederatedPolicyOverlaysAction = autoRefresh do
overlays <- query @FederatedPolicyOverlay |> orderByDesc #createdAt |> fetch
hubs <- query @Hub |> fetch
render IndexView { overlays, hubs }
action ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId } = do
overlay <- fetch federatedPolicyOverlayId
hubs <- query @Hub |> fetch
render ShowView { overlay, hubs }
action NewFederatedPolicyOverlayAction = do
let overlay = newRecord @FederatedPolicyOverlay
hubs <- query @Hub |> orderByAsc #name |> fetch
render NewView { overlay, hubs }
action CreateFederatedPolicyOverlayAction = do
let overlay = newRecord @FederatedPolicyOverlay
hubs <- query @Hub |> orderByAsc #name |> fetch
overlay
|> fill @'["title","policyText","appliesToHubs","notes"]
|> validateField #title nonEmpty
|> validateField #policyText nonEmpty
|> ifValid \case
Left o -> render NewView { overlay = o, hubs }
Right o -> do
o <- createRecord o
setSuccessMessage "Policy overlay created"
redirectTo ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId = o.id }
action EditFederatedPolicyOverlayAction { federatedPolicyOverlayId } = do
overlay <- fetch federatedPolicyOverlayId
when (overlay.status /= "draft") do
setErrorMessage "Activated overlays cannot be edited"
redirectTo ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId }
hubs <- query @Hub |> orderByAsc #name |> fetch
render EditView { overlay, hubs }
action UpdateFederatedPolicyOverlayAction { federatedPolicyOverlayId } = do
overlay <- fetch federatedPolicyOverlayId
hubs <- query @Hub |> orderByAsc #name |> fetch
when (overlay.status /= "draft") do
setErrorMessage "Activated overlays cannot be edited"
redirectTo ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId }
overlay
|> fill @'["title","policyText","appliesToHubs","notes"]
|> validateField #title nonEmpty
|> validateField #policyText nonEmpty
|> ifValid \case
Left o -> render EditView { overlay = o, hubs }
Right o -> do
updateRecord o
setSuccessMessage "Policy overlay updated"
redirectTo ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId = o.id }
action ActivateFederatedPolicyAction { federatedPolicyOverlayId } = do
overlay <- fetch federatedPolicyOverlayId
now <- getCurrentTime
overlay
|> set #status "active"
|> set #enforcedFrom (Just now)
|> updateRecord
setSuccessMessage "Policy overlay activated"
redirectTo ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId }
action RetireFederatedPolicyAction { federatedPolicyOverlayId } = do
overlay <- fetch federatedPolicyOverlayId
overlay |> set #status "retired" |> updateRecord
setSuccessMessage "Policy overlay retired"
redirectTo FederatedPolicyOverlaysAction
action PolicyComplianceDashboardAction = autoRefresh do
overlays <- query @FederatedPolicyOverlay |> filterWhere (#status, "active") |> fetch
hubs <- query @Hub |> fetch
decisions <- query @DecisionRecord |> fetch
policies <- query @PolicyReference |> fetch
render PolicyComplianceDashboardView { overlays, hubs, decisions, policies }

View File

@@ -0,0 +1,89 @@
module Web.Controller.HubRoutingRules where
import Web.Types
import Web.View.HubRoutingRules.Index
import Web.View.HubRoutingRules.Show
import Web.View.HubRoutingRules.New
import Web.View.HubRoutingRules.Edit
import Web.View.HubRoutingRules.RoutedCandidates
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Application.Helper.RoutingEngine (applyRoutingRules)
instance Controller HubRoutingRulesController where
beforeAction = ensureIsUser
action HubRoutingRulesAction = autoRefresh do
rules <- query @HubRoutingRule |> orderByDesc #priority |> fetch
hubs <- query @Hub |> fetch
render IndexView { rules, hubs }
action ShowHubRoutingRuleAction { hubRoutingRuleId } = do
rule <- fetch hubRoutingRuleId
sourceHub <- fetch rule.sourceHubId
targetHub <- fetch rule.targetHubId
render ShowView { rule, sourceHub, targetHub }
action NewHubRoutingRuleAction = do
let rule = newRecord @HubRoutingRule
hubs <- query @Hub |> orderByAsc #name |> fetch
render NewView { rule, hubs }
action CreateHubRoutingRuleAction = do
let rule = newRecord @HubRoutingRule
hubs <- query @Hub |> orderByAsc #name |> fetch
rule
|> fill @'["sourceHubId","targetHubId","matchCategory","matchWidgetType","priority","notes"]
|> validateField #sourceHubId nonEmpty
|> validateField #targetHubId nonEmpty
|> ifValid \case
Left r -> render NewView { rule = r, hubs }
Right r -> do
r <- createRecord r
setSuccessMessage "Routing rule created"
redirectTo ShowHubRoutingRuleAction { hubRoutingRuleId = r.id }
action EditHubRoutingRuleAction { hubRoutingRuleId } = do
rule <- fetch hubRoutingRuleId
hubs <- query @Hub |> orderByAsc #name |> fetch
render EditView { rule, hubs }
action UpdateHubRoutingRuleAction { hubRoutingRuleId } = do
rule <- fetch hubRoutingRuleId
hubs <- query @Hub |> orderByAsc #name |> fetch
rule
|> fill @'["matchCategory","matchWidgetType","priority","notes"]
|> ifValid \case
Left r -> render EditView { rule = r, hubs }
Right r -> do
updateRecord r
setSuccessMessage "Routing rule updated"
redirectTo ShowHubRoutingRuleAction { hubRoutingRuleId = r.id }
action ActivateRoutingRuleAction { hubRoutingRuleId } = do
rule <- fetch hubRoutingRuleId
rule |> set #status "active" |> updateRecord
setSuccessMessage "Rule activated"
redirectTo HubRoutingRulesAction
action DeactivateRoutingRuleAction { hubRoutingRuleId } = do
rule <- fetch hubRoutingRuleId
rule |> set #status "inactive" |> updateRecord
setSuccessMessage "Rule deactivated"
redirectTo HubRoutingRulesAction
action RoutedCandidatesAction { hubId } = autoRefresh do
hub <- fetch hubId
candidates <- query @RequirementCandidate
|> filterWhere (#routedToHubId, Just hubId)
|> orderByDesc #createdAt
|> fetch
render RoutedCandidatesView { hub, candidates }
action RouteNowAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
widgets <- query @Widget |> fetch
_ <- applyRoutingRules candidate widgets
setSuccessMessage "Routing re-evaluated"
redirectTo ShowRequirementCandidateAction { requirementCandidateId }

View File

@@ -0,0 +1,48 @@
module Web.Controller.StewardshipRoles where
import Web.Types
import Web.View.StewardshipRoles.Index
import Web.View.StewardshipRoles.Show
import Web.View.StewardshipRoles.New
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller StewardshipRolesController where
beforeAction = ensureIsUser
action StewardshipRolesAction = autoRefresh do
roles <- query @StewardshipRole |> orderByDesc #grantedAt |> fetch
hubs <- query @Hub |> fetch
render IndexView { roles, hubs }
action ShowStewardshipRoleAction { stewardshipRoleId } = do
role <- fetch stewardshipRoleId
hub <- fetch role.hubId
render ShowView { role, hub }
action NewStewardshipRoleAction = do
let role = newRecord @StewardshipRole
hubs <- query @Hub |> orderByAsc #name |> fetch
render NewView { role, hubs }
action CreateStewardshipRoleAction = do
let role = newRecord @StewardshipRole
hubs <- query @Hub |> orderByAsc #name |> fetch
role
|> fill @'["hubId","roleName","assignedTo","notes"]
|> validateField #roleName nonEmpty
|> validateField #assignedTo nonEmpty
|> ifValid \case
Left r -> render NewView { role = r, hubs }
Right r -> do
r <- createRecord r
setSuccessMessage "Stewardship role granted"
redirectTo ShowStewardshipRoleAction { stewardshipRoleId = r.id }
action RevokeRoleAction { stewardshipRoleId } = do
role <- fetch stewardshipRoleId
now <- getCurrentTime
role |> set #revokedAt (Just now) |> updateRecord
setSuccessMessage "Role revoked"
redirectTo StewardshipRolesAction

View File

@@ -0,0 +1,68 @@
module Web.Controller.WidgetOwnerships where
import Web.Types
import Web.View.WidgetOwnerships.Index
import Web.View.WidgetOwnerships.Show
import Web.View.WidgetOwnerships.New
import Web.View.WidgetOwnerships.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller WidgetOwnershipsController where
beforeAction = ensureIsUser
action WidgetOwnershipsAction = do
ownerships <- query @WidgetOwnership |> orderByDesc #createdAt |> fetch
widgets <- query @Widget |> fetch
hubs <- query @Hub |> fetch
render IndexView { ownerships, widgets, hubs }
action ShowWidgetOwnershipAction { widgetOwnershipId } = do
ownership <- fetch widgetOwnershipId
widget <- fetch ownership.widgetId
ownerHub <- fetch ownership.ownerHubId
mStewardHub <- case ownership.stewardHubId of
Nothing -> pure Nothing
Just sid -> Just <$> fetch sid
render ShowView { ownership, widget, ownerHub, mStewardHub }
action NewWidgetOwnershipAction = do
let ownership = newRecord @WidgetOwnership
widgets <- query @Widget |> orderByAsc #name |> fetch
hubs <- query @Hub |> orderByAsc #name |> fetch
render NewView { ownership, widgets, hubs }
action CreateWidgetOwnershipAction = do
let ownership = newRecord @WidgetOwnership
widgets <- query @Widget |> orderByAsc #name |> fetch
hubs <- query @Hub |> orderByAsc #name |> fetch
ownership
|> fill @'["widgetId","ownerHubId","stewardHubId","ownershipType","effectiveFrom","effectiveUntil","notes"]
|> validateField #ownershipType (isInList ["local","delegated","global"])
|> ifValid \case
Left o -> render NewView { ownership = o, widgets, hubs }
Right o -> do
o <- createRecord o
setSuccessMessage "Ownership assigned"
redirectTo ShowWidgetOwnershipAction { widgetOwnershipId = o.id }
action EditWidgetOwnershipAction { widgetOwnershipId } = do
ownership <- fetch widgetOwnershipId
widgets <- query @Widget |> orderByAsc #name |> fetch
hubs <- query @Hub |> orderByAsc #name |> fetch
render EditView { ownership, widgets, hubs }
action UpdateWidgetOwnershipAction { widgetOwnershipId } = do
ownership <- fetch widgetOwnershipId
widgets <- query @Widget |> orderByAsc #name |> fetch
hubs <- query @Hub |> orderByAsc #name |> fetch
ownership
|> fill @'["stewardHubId","ownershipType","effectiveUntil","notes"]
|> validateField #ownershipType (isInList ["local","delegated","global"])
|> ifValid \case
Left o -> render EditView { ownership = o, widgets, hubs }
Right o -> do
updateRecord o
setSuccessMessage "Ownership updated"
redirectTo ShowWidgetOwnershipAction { widgetOwnershipId = o.id }