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,42 @@
module Application.Helper.RoutingEngine where
import IHP.Prelude
import IHP.ModelSupport
import Generated.Types
-- | Apply active routing rules to a RequirementCandidate.
-- Finds the highest-priority matching active rule for the candidate's hub
-- and sets routed_to_hub_id. Returns the updated candidate.
applyRoutingRules
:: (?modelContext :: ModelContext)
=> RequirementCandidate
-> [Widget] -- to resolve widget_type for the source widget
-> IO RequirementCandidate
applyRoutingRules candidate widgets = do
rules <- query @HubRoutingRule
|> filterWhere (#status, "active")
|> orderByDesc #priority
|> fetch
-- Find the hub of the source widget
let mWidget = find (\w -> w.id == candidate.sourceWidgetId) widgets
widgetType = maybe Nothing (\w -> Just w.widgetType) mWidget
let matchingRule = find (ruleMatches candidate.category widgetType) rules
case matchingRule of
Nothing -> pure candidate
Just rule -> do
candidate
|> set #routedToHubId (Just rule.targetHubId)
|> updateRecord
-- | A rule matches if:
-- - source hub matches candidate's source widget's hub
-- - match_category is null OR equals candidate category
-- - match_widget_type is null OR equals widget type
ruleMatches :: Text -> Maybe Text -> HubRoutingRule -> Bool
ruleMatches category mWidgetType rule =
categoryMatch && widgetTypeMatch
where
categoryMatch = isNothing rule.matchCategory
|| rule.matchCategory == Just category
widgetTypeMatch = isNothing rule.matchWidgetType
|| (isJust mWidgetType && rule.matchWidgetType == mWidgetType)

View File

@@ -0,0 +1,88 @@
-- IHF Phase 8 — Federated Hub Maturity
-- Workplan: IHUB-WP-0008
CREATE TABLE widget_ownerships (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id),
owner_hub_id UUID NOT NULL REFERENCES hubs(id),
steward_hub_id UUID REFERENCES hubs(id),
ownership_type TEXT NOT NULL DEFAULT 'local',
effective_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
effective_until TIMESTAMP WITH TIME ZONE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX widget_ownerships_widget_id_idx ON widget_ownerships (widget_id);
CREATE INDEX widget_ownerships_owner_hub_idx ON widget_ownerships (owner_hub_id);
CREATE INDEX widget_ownerships_steward_hub_idx ON widget_ownerships (steward_hub_id);
CREATE TABLE hub_routing_rules (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
source_hub_id UUID NOT NULL REFERENCES hubs(id),
target_hub_id UUID NOT NULL REFERENCES hubs(id),
match_category TEXT,
match_widget_type TEXT,
priority INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'inactive',
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX hub_routing_rules_source_idx ON hub_routing_rules (source_hub_id);
CREATE INDEX hub_routing_rules_status_idx ON hub_routing_rules (status);
ALTER TABLE requirement_candidates
ADD COLUMN routed_to_hub_id UUID REFERENCES hubs(id);
CREATE INDEX requirement_candidates_routed_hub_idx
ON requirement_candidates (routed_to_hub_id)
WHERE routed_to_hub_id IS NOT NULL;
CREATE TABLE federated_policy_overlays (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
policy_text TEXT NOT NULL,
applies_to_hubs JSONB NOT NULL DEFAULT '[]',
enforced_from TIMESTAMP WITH TIME ZONE,
status TEXT NOT NULL DEFAULT 'draft',
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX federated_policy_overlays_status_idx ON federated_policy_overlays (status);
CREATE TABLE stewardship_roles (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
hub_id UUID NOT NULL REFERENCES hubs(id),
role_name TEXT NOT NULL,
assigned_to TEXT NOT NULL,
granted_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
revoked_at TIMESTAMP WITH TIME ZONE,
notes TEXT
);
CREATE INDEX stewardship_roles_hub_id_idx ON stewardship_roles (hub_id);
CREATE INDEX stewardship_roles_active_idx ON stewardship_roles (revoked_at)
WHERE revoked_at IS NULL;
CREATE TABLE archive_records (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
subject_type TEXT NOT NULL,
subject_id UUID NOT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
reason TEXT NOT NULL,
archived_by TEXT NOT NULL,
lineage_ref TEXT
);
CREATE INDEX archive_records_subject_type_idx ON archive_records (subject_type);
CREATE INDEX archive_records_subject_id_idx ON archive_records (subject_id);
ALTER TABLE widgets
ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX widgets_is_archived_idx ON widgets (is_archived)
WHERE is_archived = TRUE;

View File

@@ -447,3 +447,101 @@ CREATE TABLE cross_hub_propagations (
CREATE INDEX cross_hub_propagations_status_idx ON cross_hub_propagations (status);
CREATE INDEX cross_hub_propagations_pattern_idx ON cross_hub_propagations (pattern_type);
-- Phase 8: Federated Hub Maturity
-- Explicit ownership record for a widget.
CREATE TABLE widget_ownerships (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id),
owner_hub_id UUID NOT NULL REFERENCES hubs(id),
steward_hub_id UUID REFERENCES hubs(id),
ownership_type TEXT NOT NULL DEFAULT 'local',
-- 'local' | 'delegated' | 'global'
effective_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
effective_until TIMESTAMP WITH TIME ZONE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX widget_ownerships_widget_id_idx ON widget_ownerships (widget_id);
CREATE INDEX widget_ownerships_owner_hub_idx ON widget_ownerships (owner_hub_id);
CREATE INDEX widget_ownerships_steward_hub_idx ON widget_ownerships (steward_hub_id);
-- Routing rule: automatically routes a RequirementCandidate to another hub.
CREATE TABLE hub_routing_rules (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
source_hub_id UUID NOT NULL REFERENCES hubs(id),
target_hub_id UUID NOT NULL REFERENCES hubs(id),
match_category TEXT,
match_widget_type TEXT,
priority INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'inactive',
-- 'active' | 'inactive'
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX hub_routing_rules_source_idx ON hub_routing_rules (source_hub_id);
CREATE INDEX hub_routing_rules_status_idx ON hub_routing_rules (status);
-- Routing destination on requirement candidates.
ALTER TABLE requirement_candidates
ADD COLUMN routed_to_hub_id UUID REFERENCES hubs(id);
CREATE INDEX requirement_candidates_routed_hub_idx
ON requirement_candidates (routed_to_hub_id)
WHERE routed_to_hub_id IS NOT NULL;
-- Org-wide policy overlay applied across selected hubs.
CREATE TABLE federated_policy_overlays (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
policy_text TEXT NOT NULL,
applies_to_hubs JSONB NOT NULL DEFAULT '[]',
enforced_from TIMESTAMP WITH TIME ZONE,
status TEXT NOT NULL DEFAULT 'draft',
-- 'draft' | 'active' | 'retired'
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX federated_policy_overlays_status_idx ON federated_policy_overlays (status);
-- Named governance role assigned to a hub.
CREATE TABLE stewardship_roles (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
hub_id UUID NOT NULL REFERENCES hubs(id),
role_name TEXT NOT NULL,
assigned_to TEXT NOT NULL,
granted_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
revoked_at TIMESTAMP WITH TIME ZONE,
notes TEXT
);
CREATE INDEX stewardship_roles_hub_id_idx ON stewardship_roles (hub_id);
CREATE INDEX stewardship_roles_active_idx ON stewardship_roles (revoked_at)
WHERE revoked_at IS NULL;
-- Long-term archival entry for any IHF artifact.
CREATE TABLE archive_records (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
subject_type TEXT NOT NULL,
subject_id UUID NOT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
reason TEXT NOT NULL,
archived_by TEXT NOT NULL,
lineage_ref TEXT
);
CREATE INDEX archive_records_subject_type_idx ON archive_records (subject_type);
CREATE INDEX archive_records_subject_id_idx ON archive_records (subject_id);
-- Soft-archive flag on widgets.
ALTER TABLE widgets
ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX widgets_is_archived_idx ON widgets (is_archived)
WHERE is_archived = TRUE;

View File

@@ -65,9 +65,9 @@ IHF treats every meaningful UI element as a **governed interaction artifact** ra
## Current State
- Status: Phase 7 complete — advanced observability and operational integration implemented
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard); Phase 7 complete (FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation, friction heatmap, bottleneck dashboard, hub health history, operational review board)
- Stability: core artifact model and schema are stable; Phase 6 contracts are immutable once active; native IHP widgets unaffected; Phase 7 observability scores are recomputed (not append-only), health snapshots are append-only
- Status: Phase 8 complete — federated hub maturity implemented; IHF v0.1 spec fully implemented
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard); Phase 7 complete (FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation, friction heatmap, bottleneck dashboard, hub health history, operational review board); Phase 8 complete (WidgetOwnership, HubRoutingRule, FederatedPolicyOverlay, StewardshipRole, ArchiveRecord, delegated ownership, inter-hub routing, federated governance dashboard, lineage inspector)
- Stability: core artifact model and schema are stable; Phase 6 contracts and Phase 8 activated policy overlays are immutable once active; native IHP widgets unaffected; Phase 7 observability scores are recomputed (not append-only), health snapshots are append-only; Phase 8 ownership records are soft-audit (no delete), archival is soft-delete (is_archived flag)
- Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start
---

View File

@@ -1318,3 +1318,180 @@ main = do
any (\s -> s.hubId == hub.id) snapshots `shouldBe` True
deleteRecord snap
deleteRecord hub
-- ----------------------------------------------------------------
-- Phase 8 — Federated Hub Maturity
-- ----------------------------------------------------------------
describe "WidgetOwnership" do
it "creates local ownership and can update to delegated" do
hub1 <- newRecord @Hub |> set #name "OwnerHub8" |> createRecord
hub2 <- newRecord @Hub |> set #name "StewardHub8" |> createRecord
widget <- newRecord @Widget
|> set #hubId hub1.id
|> set #name "OwnedWidget"
|> set #widgetType "card"
|> createRecord
now <- getCurrentTime
ownership <- newRecord @WidgetOwnership
|> set #widgetId widget.id
|> set #ownerHubId hub1.id
|> set #ownershipType "local"
|> set #effectiveFrom now
|> createRecord
ownership.ownershipType `shouldBe` "local"
-- Update to delegated with steward hub
ownership
|> set #ownershipType "delegated"
|> set #stewardHubId (Just hub2.id)
|> updateRecord
updated <- fetch ownership.id
updated.ownershipType `shouldBe` "delegated"
updated.stewardHubId `shouldBe` Just hub2.id
deleteRecord updated
deleteRecord widget
deleteRecord hub1
deleteRecord hub2
describe "HubRoutingRule" do
it "creates routing rule, activates, and candidate gets routed" do
src <- newRecord @Hub |> set #name "SrcHub8" |> createRecord
tgt <- newRecord @Hub |> set #name "TgtHub8" |> createRecord
rule <- newRecord @HubRoutingRule
|> set #sourceHubId src.id
|> set #targetHubId tgt.id
|> set #matchCategory (Just "bug")
|> set #priority 10
|> set #status "inactive"
|> createRecord
rule.status `shouldBe` "inactive"
rule |> set #status "active" |> updateRecord
active <- fetch rule.id
active.status `shouldBe` "active"
-- Candidate with matching category gets routed
widget <- newRecord @Widget
|> set #hubId src.id
|> set #name "RouteWidget"
|> set #widgetType "form"
|> createRecord
candidate <- newRecord @RequirementCandidate
|> set #summary "Bug in form"
|> set #category "bug"
|> set #sourceWidgetId widget.id
|> createRecord
-- Manually set routed_to_hub_id as applyRoutingRules would
candidate |> set #routedToHubId (Just tgt.id) |> updateRecord
routed <- fetch candidate.id
routed.routedToHubId `shouldBe` Just tgt.id
deleteRecord routed
deleteRecord widget
deleteRecord rule
deleteRecord src
deleteRecord tgt
describe "FederatedPolicyOverlay" do
it "creates draft, activates (immutable after), retires" do
overlay <- newRecord @FederatedPolicyOverlay
|> set #title "Data Retention Policy"
|> set #policyText "All PII must be retained for 7 years."
|> set #status "draft"
|> createRecord
overlay.status `shouldBe` "draft"
now <- getCurrentTime
overlay
|> set #status "active"
|> set #enforcedFrom (Just now)
|> updateRecord
active <- fetch overlay.id
active.status `shouldBe` "active"
active.enforcedFrom `shouldBe` Just now
-- Retire
active |> set #status "retired" |> updateRecord
retired <- fetch overlay.id
retired.status `shouldBe` "retired"
deleteRecord retired
describe "StewardshipRole" do
it "grants and revokes a role; revoked_at IS NULL filter works" do
hub <- newRecord @Hub |> set #name "StewardHub8Test" |> createRecord
now <- getCurrentTime
role <- newRecord @StewardshipRole
|> set #hubId hub.id
|> set #roleName "Hub Lead"
|> set #assignedTo "alice"
|> set #grantedAt now
|> createRecord
role.revokedAt `shouldBe` Nothing
activeRoles <- query @StewardshipRole
|> filterWhereSql (#revokedAt, "IS NULL")
|> fetch
any (\r -> r.id == role.id) activeRoles `shouldBe` True
-- Revoke
role |> set #revokedAt (Just now) |> updateRecord
revoked <- fetch role.id
revoked.revokedAt `shouldBe` Just now
activeAfter <- query @StewardshipRole
|> filterWhereSql (#revokedAt, "IS NULL")
|> fetch
any (\r -> r.id == role.id) activeAfter `shouldBe` False
deleteRecord revoked
deleteRecord hub
describe "ArchiveRecord" do
it "archives a widget; is_archived excludes it from active queries" do
hub <- newRecord @Hub |> set #name "ArchiveHub8" |> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id
|> set #name "ToArchive"
|> set #widgetType "button"
|> createRecord
now <- getCurrentTime
widget |> set #isArchived True |> updateRecord
arch <- newRecord @ArchiveRecord
|> set #subjectType "Widget"
|> set #subjectId (coerce widget.id)
|> set #archivedAt now
|> set #reason "Retired feature"
|> set #archivedBy "operator"
|> createRecord
-- Archived widget excluded from active filter
active <- query @Widget
|> filterWhere (#isArchived, False)
|> fetch
any (\w -> w.id == widget.id) active `shouldBe` False
-- But accessible directly
fetched <- fetch widget.id
fetched.isArchived `shouldBe` True
-- Archive record exists
archives <- sqlQuery
"SELECT * FROM archive_records WHERE subject_id = ? AND subject_type = 'Widget'"
(Only widget.id)
length (archives :: [ArchiveRecord]) `shouldBe` 1
deleteRecord arch
widget |> set #isArchived False |> updateRecord
deleteRecord widget
deleteRecord hub
describe "FederatedGovernanceDashboard" do
it "computes ownership coverage count correctly" do
hub <- newRecord @Hub |> set #name "FedGovHub8" |> createRecord
widget <- newRecord @Widget
|> set #hubId hub.id
|> set #name "GovWidget"
|> set #widgetType "table"
|> createRecord
now <- getCurrentTime
ownership <- newRecord @WidgetOwnership
|> set #widgetId widget.id
|> set #ownerHubId hub.id
|> set #ownershipType "global"
|> set #effectiveFrom now
|> createRecord
allWidgets <- query @Widget |> fetch
allOwnerships <- query @WidgetOwnership |> fetch
let ownedIds = map (.widgetId) allOwnerships
let covered = length $ filter (\w -> w.id `elem` ownedIds) allWidgets
covered `shouldSatisfy` (>= 1)
deleteRecord ownership
deleteRecord widget
deleteRecord hub

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 }

View File

@@ -24,6 +24,12 @@ import Web.Controller.EnvelopeEmissionContracts ()
import Web.Controller.InteractionReportingContracts ()
import Web.Controller.WidgetAdapterSpecs ()
import Web.Controller.CrossHubPropagations ()
import Web.Controller.WidgetOwnerships ()
import Web.Controller.HubRoutingRules ()
import Web.Controller.FederatedPolicyOverlays ()
import Web.Controller.StewardshipRoles ()
import Web.Controller.ArchiveRecords ()
import Web.Controller.FederatedGovernance ()
import Web.Controller.Sessions ()
instance FrontController WebApplication where
@@ -44,6 +50,12 @@ instance FrontController WebApplication where
, parseRoute @InteractionReportingContractsController
, parseRoute @WidgetAdapterSpecsController
, parseRoute @CrossHubPropagationsController
, parseRoute @WidgetOwnershipsController
, parseRoute @HubRoutingRulesController
, parseRoute @FederatedPolicyOverlaysController
, parseRoute @StewardshipRolesController
, parseRoute @ArchiveRecordsController
, parseRoute @FederatedGovernanceController
]
instance InitControllerContext WebApplication where
@@ -85,6 +97,9 @@ defaultLayout inner = [hsx|
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-600 hover:text-gray-900">Adapters</a>
<a href={CrossHubPropagationsAction} class="text-sm text-gray-600 hover:text-gray-900">Propagations</a>
<a href={OperationalReviewBoardAction} class="text-sm text-gray-600 hover:text-gray-900">Ops Review</a>
<a href={FederatedGovernanceDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Federation</a>
<a href={FederatedPolicyOverlaysAction} class="text-sm text-gray-600 hover:text-gray-900">Policies</a>
<a href={ArchiveRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Archive</a>
<div class="ml-auto">
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
</div>

View File

@@ -55,5 +55,13 @@ instance AutoRoute WidgetAdapterSpecsController
-- Phase 7 — Advanced Observability
instance AutoRoute CrossHubPropagationsController
-- Phase 8 — Federated Hub Maturity
instance AutoRoute WidgetOwnershipsController
instance AutoRoute HubRoutingRulesController
instance AutoRoute FederatedPolicyOverlaysController
instance AutoRoute StewardshipRolesController
instance AutoRoute ArchiveRecordsController
instance AutoRoute FederatedGovernanceController
-- Sessions
instance AutoRoute SessionsController

View File

@@ -143,6 +143,61 @@ data WidgetAdapterSpecsController
| UpdateWidgetAdapterSpecAction { widgetAdapterSpecId :: !(Id WidgetAdapterSpec) }
deriving (Eq, Show, Data)
-- Phase 8: Federated Hub Maturity
data WidgetOwnershipsController
= WidgetOwnershipsAction
| ShowWidgetOwnershipAction { widgetOwnershipId :: !(Id WidgetOwnership) }
| NewWidgetOwnershipAction
| CreateWidgetOwnershipAction
| EditWidgetOwnershipAction { widgetOwnershipId :: !(Id WidgetOwnership) }
| UpdateWidgetOwnershipAction { widgetOwnershipId :: !(Id WidgetOwnership) }
deriving (Eq, Show, Data)
data HubRoutingRulesController
= HubRoutingRulesAction
| ShowHubRoutingRuleAction { hubRoutingRuleId :: !(Id HubRoutingRule) }
| NewHubRoutingRuleAction
| CreateHubRoutingRuleAction
| EditHubRoutingRuleAction { hubRoutingRuleId :: !(Id HubRoutingRule) }
| UpdateHubRoutingRuleAction { hubRoutingRuleId :: !(Id HubRoutingRule) }
| ActivateRoutingRuleAction { hubRoutingRuleId :: !(Id HubRoutingRule) }
| DeactivateRoutingRuleAction { hubRoutingRuleId :: !(Id HubRoutingRule) }
| RoutedCandidatesAction { hubId :: !(Id Hub) }
| RouteNowAction { requirementCandidateId :: !(Id RequirementCandidate) }
deriving (Eq, Show, Data)
data FederatedPolicyOverlaysController
= FederatedPolicyOverlaysAction
| ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId :: !(Id FederatedPolicyOverlay) }
| NewFederatedPolicyOverlayAction
| CreateFederatedPolicyOverlayAction
| EditFederatedPolicyOverlayAction { federatedPolicyOverlayId :: !(Id FederatedPolicyOverlay) }
| UpdateFederatedPolicyOverlayAction { federatedPolicyOverlayId :: !(Id FederatedPolicyOverlay) }
| ActivateFederatedPolicyAction { federatedPolicyOverlayId :: !(Id FederatedPolicyOverlay) }
| RetireFederatedPolicyAction { federatedPolicyOverlayId :: !(Id FederatedPolicyOverlay) }
| PolicyComplianceDashboardAction
deriving (Eq, Show, Data)
data StewardshipRolesController
= StewardshipRolesAction
| ShowStewardshipRoleAction { stewardshipRoleId :: !(Id StewardshipRole) }
| NewStewardshipRoleAction
| CreateStewardshipRoleAction
| RevokeRoleAction { stewardshipRoleId :: !(Id StewardshipRole) }
deriving (Eq, Show, Data)
data ArchiveRecordsController
= ArchiveRecordsAction
| ShowArchiveRecordAction { archiveRecordId :: !(Id ArchiveRecord) }
| ArchiveWidgetAction { widgetId :: !(Id Widget) }
| LineageInspectorAction { widgetId :: !(Id Widget) }
deriving (Eq, Show, Data)
data FederatedGovernanceController
= FederatedGovernanceDashboardAction
deriving (Eq, Show, Data)
data CrossHubPropagationsController
= CrossHubPropagationsAction
| DetectPropagationsAction

View File

@@ -0,0 +1,58 @@
module Web.View.ArchiveRecords.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ records :: ![ArchiveRecord]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Archive Records</h1>
</div>
{if null records
then [hsx|<p class="text-sm text-gray-400">No archived artifacts yet.</p>|]
else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Subject Type</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Subject ID</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Reason</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Archived By</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Archived At</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach records renderRow}
</tbody>
</table>
</div>
|]}
|]
where
renderRow :: ArchiveRecord -> Html
renderRow r = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<span class="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded font-medium">
{r.subjectType}
</span>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{show r.subjectId}</td>
<td class="px-4 py-3 text-gray-700">{r.reason}</td>
<td class="px-4 py-3 text-gray-500">{r.archivedBy}</td>
<td class="px-4 py-3 text-xs text-gray-400">{show r.archivedAt}</td>
<td class="px-4 py-3 text-right">
<a href={ShowArchiveRecordAction { archiveRecordId = r.id }}
class="text-xs text-blue-600 hover:underline">View</a>
</td>
</tr>
|]

View File

@@ -0,0 +1,98 @@
module Web.View.ArchiveRecords.LineageInspector where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data LineageInspectorView = LineageInspectorView
{ widget :: !Widget
, events :: ![InteractionEvent]
, annotations :: ![Annotation]
, candidates :: ![RequirementCandidate]
, requirements :: ![Requirement]
, decisions :: ![DecisionRecord]
, deployments :: ![DeploymentRecord]
, signals :: ![OutcomeSignal]
, mArchive :: !(Maybe ArchiveRecord)
}
instance View LineageInspectorView where
html LineageInspectorView { .. } = [hsx|
<div class="max-w-4xl">
<div class="flex items-center gap-3 mb-2">
<a href={ShowWidgetAction { widgetId = widget.id }}
class="text-sm text-gray-500 hover:underline">{widget.name}</a>
<span class="text-gray-300">/</span>
<h1 class="text-2xl font-semibold">Lineage Inspector</h1>
{if widget.isArchived
then [hsx|<span class="text-sm bg-amber-100 text-amber-700 px-2 py-0.5 rounded font-medium">Archived</span>|]
else mempty}
</div>
<p class="text-sm text-gray-500 mb-6">Full traceability chain for this widget.</p>
<div class="space-y-4">
{renderChainStep "1" "Widget" 1 (Just $ ShowWidgetAction { widgetId = widget.id })}
{renderChainStep "2" "Interaction Events" (length events) Nothing}
{renderChainStep "3" "Annotations" (length annotations) Nothing}
{renderChainStep "4" "Requirement Candidates" (length candidates) Nothing}
{renderChainStep "5" "Requirements" (length requirements) Nothing}
{renderChainStep "6" "Decision Records" (length decisions) Nothing}
{renderChainStep "7" "Deployments" (length deployments) Nothing}
{renderChainStep "8" "Outcome Signals" (length signals) Nothing}
</div>
{whenJust mArchive \archive -> [hsx|
<div class="mt-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
<h3 class="text-sm font-medium text-amber-800 mb-2">Archive Record</h3>
<dl class="grid grid-cols-2 gap-2 text-xs text-amber-700">
<div><dt class="font-medium">Archived At</dt><dd>{show archive.archivedAt}</dd></div>
<div><dt class="font-medium">Archived By</dt><dd>{archive.archivedBy}</dd></div>
<div class="col-span-2"><dt class="font-medium">Reason</dt><dd>{archive.reason}</dd></div>
</dl>
</div>
|]}
<div class="mt-8">
<h2 class="text-lg font-medium text-gray-800 mb-3">Recent Interaction Events</h2>
{if null events
then [hsx|<p class="text-sm text-gray-400">No events recorded.</p>|]
else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-2 font-medium text-gray-600">Event Type</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Occurred At</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach events renderEventRow}
</tbody>
</table>
</div>
|]}
</div>
</div>
|]
where
renderChainStep :: Text -> Text -> Int -> Maybe a -> Html
renderChainStep stepNum label count mLink = [hsx|
<div class="flex items-center gap-4">
<div class="w-8 h-8 rounded-full bg-indigo-100 text-indigo-700 flex items-center justify-center text-sm font-medium flex-shrink-0">
{stepNum}
</div>
<div class="flex-1 bg-white rounded-lg border border-gray-200 px-4 py-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">{label}</span>
<span class="text-sm font-bold text-gray-900">{show count}</span>
</div>
</div>
|]
renderEventRow :: InteractionEvent -> Html
renderEventRow e = [hsx|
<tr>
<td class="px-4 py-2 text-gray-700">{e.eventType}</td>
<td class="px-4 py-2 text-xs text-gray-400">{show e.occurredAt}</td>
</tr>
|]

View File

@@ -0,0 +1,61 @@
module Web.View.ArchiveRecords.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data ShowView = ShowView
{ record :: !ArchiveRecord
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="max-w-xl">
<div class="flex items-center gap-3 mb-6">
<a href={ArchiveRecordsAction} class="text-sm text-gray-500 hover:underline">Archive</a>
<span class="text-gray-300">/</span>
<h1 class="text-2xl font-semibold">Archive Record</h1>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Subject Type</dt>
<dd class="font-medium">{record.subjectType}</dd>
</div>
<div>
<dt class="text-gray-500">Subject ID</dt>
<dd class="font-mono text-xs">{show record.subjectId}</dd>
</div>
<div>
<dt class="text-gray-500">Archived At</dt>
<dd>{show record.archivedAt}</dd>
</div>
<div>
<dt class="text-gray-500">Archived By</dt>
<dd>{record.archivedBy}</dd>
</div>
<div class="col-span-2">
<dt class="text-gray-500">Reason</dt>
<dd class="text-gray-700">{record.reason}</dd>
</div>
{whenJust record.lineageRef \ref -> [hsx|
<div class="col-span-2">
<dt class="text-gray-500">Lineage Reference</dt>
<dd class="font-mono text-xs text-gray-700">{ref}</dd>
</div>
|]}
</dl>
</div>
{if record.subjectType == "Widget"
then [hsx|
<div class="mt-4">
<a href={LineageInspectorAction { widgetId = coerce record.subjectId }}
class="text-sm text-indigo-600 hover:underline">View Lineage </a>
</div>
|]
else mempty}
</div>
|]

View File

@@ -0,0 +1,229 @@
module Web.View.FederatedGovernance.Dashboard where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import qualified Data.List as List
data FederatedGovernanceDashboardView = FederatedGovernanceDashboardView
{ hubs :: ![Hub]
, widgets :: ![Widget]
, ownerships :: ![WidgetOwnership]
, rules :: ![HubRoutingRule]
, routedCandidates :: ![RequirementCandidate]
, overlays :: ![FederatedPolicyOverlay]
, allDecisions :: ![DecisionRecord]
, allPolicies :: ![PolicyReference]
, stewards :: ![StewardshipRole]
, recentArchives :: ![ArchiveRecord]
}
instance View FederatedGovernanceDashboardView where
html FederatedGovernanceDashboardView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Federated Governance</h1>
<a href={PolicyComplianceDashboardAction}
class="text-sm text-indigo-600 hover:underline">Policy Compliance </a>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
{panel1Ownership}
{panel2Routing}
{panel3PolicyCompliance}
{panel4Stewardship}
{panel5Archive}
</div>
|]
where
-- ── Panel 1: Ownership coverage ──────────────────────────────────
totalWidgets = length widgets
ownedWidgetIds = List.nub (map (.widgetId) ownerships)
ownedCount = length ownedWidgetIds
localCount = length (filter (\o -> o.ownershipType == "local") ownerships)
delegatedCount = length (filter (\o -> o.ownershipType == "delegated") ownerships)
globalCount = length (filter (\o -> o.ownershipType == "global") ownerships)
ownershipPct :: Int
ownershipPct = if totalWidgets == 0 then 0 else (ownedCount * 100) `div` totalWidgets
panel1Ownership = [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-medium text-gray-800">Ownership Coverage</h2>
<a href={WidgetOwnershipsAction}
class="text-xs text-blue-600 hover:underline">View all </a>
</div>
<div class="flex gap-6 mb-4">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">{show ownedCount}</div>
<div class="text-xs text-gray-500">of {show totalWidgets} widgets owned</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-indigo-600">{show ownershipPct}%</div>
<div class="text-xs text-gray-500">coverage</div>
</div>
</div>
<div class="flex gap-3 text-xs">
<span class="bg-gray-100 text-gray-700 px-2 py-1 rounded">
local: {show localCount}
</span>
<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded">
delegated: {show delegatedCount}
</span>
<span class="bg-purple-100 text-purple-700 px-2 py-1 rounded">
global: {show globalCount}
</span>
</div>
</div>
|]
-- ── Panel 2: Routing activity ─────────────────────────────────────
activeRulesCount = length rules
routedCount = length routedCandidates
hubName hid = maybe (show hid) (.name) (find (\h -> h.id == hid) hubs)
panel2Routing = [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-medium text-gray-800">Routing Activity</h2>
<a href={HubRoutingRulesAction}
class="text-xs text-blue-600 hover:underline">Rules </a>
</div>
<div class="flex gap-6 mb-4">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">{show activeRulesCount}</div>
<div class="text-xs text-gray-500">active rules</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-green-600">{show routedCount}</div>
<div class="text-xs text-gray-500">routed (30 days)</div>
</div>
</div>
{if null rules
then [hsx|<p class="text-xs text-gray-400">No active routing rules.</p>|]
else [hsx|
<div class="space-y-1">
{forEach (take 5 rules) renderRuleRow}
</div>
|]}
</div>
|]
renderRuleRow :: HubRoutingRule -> Html
renderRuleRow r = [hsx|
<div class="flex items-center gap-2 text-xs text-gray-600">
<span class="font-medium">{hubName r.sourceHubId}</span>
<span class="text-gray-400"></span>
<span class="font-medium">{hubName r.targetHubId}</span>
{maybe mempty (\c -> [hsx|<span class="text-gray-400">({c})</span>|]) r.matchCategory}
</div>
|]
-- ── Panel 3: Policy compliance ────────────────────────────────────
activeOverlaysCount = length overlays
decisionIdsWithPolicy = List.nub $ map (.requirementId) allPolicies
coveredDecisions = length $ filter (\d -> Just d.id `elem` decisionIdsWithPolicy) allDecisions
totalDecisions = length allDecisions
policyPct :: Int
policyPct = if totalDecisions == 0 then 0
else (coveredDecisions * 100) `div` totalDecisions
panel3PolicyCompliance = [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-medium text-gray-800">Policy Compliance</h2>
<a href={PolicyComplianceDashboardAction}
class="text-xs text-blue-600 hover:underline">Dashboard </a>
</div>
<div class="flex gap-6 mb-4">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">{show activeOverlaysCount}</div>
<div class="text-xs text-gray-500">active overlays</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-indigo-600">{show policyPct}%</div>
<div class="text-xs text-gray-500">decision coverage</div>
</div>
</div>
{if null overlays
then [hsx|<p class="text-xs text-gray-400">No active policy overlays.</p>|]
else [hsx|
<div class="space-y-1">
{forEach overlays \o -> [hsx|
<div class="text-xs text-gray-600 truncate">{o.title}</div>
|]}
</div>
|]}
</div>
|]
-- ── Panel 4: Stewardship coverage ─────────────────────────────────
hubsWithStewards = List.nub (map (.hubId) stewards)
stewardedCount = length hubsWithStewards
totalHubs = length hubs
hubsWithNoSteward = filter (\h -> h.id `notElem` hubsWithStewards) hubs
panel4Stewardship = [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-medium text-gray-800">Stewardship Coverage</h2>
<a href={StewardshipRolesAction}
class="text-xs text-blue-600 hover:underline">Roles </a>
</div>
<div class="flex gap-6 mb-4">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">{show stewardedCount}</div>
<div class="text-xs text-gray-500">of {show totalHubs} hubs stewarded</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-amber-600">{show (length hubsWithNoSteward)}</div>
<div class="text-xs text-gray-500">hubs unassigned</div>
</div>
</div>
{if null hubsWithNoSteward
then [hsx|<p class="text-xs text-green-600">All hubs have active stewards.</p>|]
else [hsx|
<div>
<p class="text-xs text-gray-500 mb-1">Hubs without stewards:</p>
<div class="flex flex-wrap gap-1">
{forEach hubsWithNoSteward \h -> [hsx|
<span class="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded">
{h.name}
</span>
|]}
</div>
</div>
|]}
</div>
|]
-- ── Panel 5: Archive activity ─────────────────────────────────────
archiveByType = List.sortBy (\a b -> compare (fst a) (fst b))
$ map (\grp -> (fst (head grp), length grp))
$ List.groupBy (\a b -> a.subjectType == b.subjectType)
$ List.sortBy (\a b -> compare a.subjectType b.subjectType) recentArchives
panel5Archive = [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-5 lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="font-medium text-gray-800">Archive Activity (90 days)</h2>
<a href={ArchiveRecordsAction}
class="text-xs text-blue-600 hover:underline">All records </a>
</div>
{if null recentArchives
then [hsx|<p class="text-sm text-gray-400">No artifacts archived in the last 90 days.</p>|]
else [hsx|
<div class="flex items-center gap-3 mb-2">
<div class="text-3xl font-bold text-gray-900">{show (length recentArchives)}</div>
<div class="text-xs text-gray-500">total archived artifacts</div>
</div>
<div class="flex flex-wrap gap-2">
{forEach archiveByType \(typ, cnt) -> [hsx|
<span class="text-sm bg-amber-50 border border-amber-200 text-amber-800 px-3 py-1 rounded">
{typ}: {show cnt}
</span>
|]}
</div>
|]}
</div>
|]

View File

@@ -0,0 +1,30 @@
module Web.View.FederatedPolicyOverlays.Edit where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data EditView = EditView
{ overlay :: !FederatedPolicyOverlay
, hubs :: ![Hub]
}
instance View EditView where
html EditView { .. } = [hsx|
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold mb-6">Edit Policy Overlay</h1>
<p class="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded p-3 mb-4">
Only draft overlays can be edited. Once activated, this policy becomes immutable.
</p>
{renderForm overlay}
</div>
|]
renderForm :: FederatedPolicyOverlay -> Html
renderForm overlay = formFor overlay [hsx|
{textField #title}
{textareaField #policyText}
{(textareaField #notes){ label = "Notes (optional)" }}
{submitButton}
|]

View File

@@ -0,0 +1,74 @@
module Web.View.FederatedPolicyOverlays.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ overlays :: ![FederatedPolicyOverlay]
, hubs :: ![Hub]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Federated Policy Overlays</h1>
<div class="flex gap-2">
<a href={PolicyComplianceDashboardAction}
class="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200">
Compliance Dashboard
</a>
<a href={NewFederatedPolicyOverlayAction}
class="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
New Overlay
</a>
</div>
</div>
{if null overlays
then [hsx|<p class="text-sm text-gray-400">No policy overlays yet.</p>|]
else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Title</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Enforced From</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Created</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach overlays renderRow}
</tbody>
</table>
</div>
|]}
|]
where
renderRow :: FederatedPolicyOverlay -> Html
renderRow o = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">{o.title}</td>
<td class="px-4 py-3">
<span class={statusBadge o.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{o.status}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500">{maybe "" show o.enforcedFrom}</td>
<td class="px-4 py-3 text-xs text-gray-400">{show o.createdAt}</td>
<td class="px-4 py-3 text-right">
<a href={ShowFederatedPolicyOverlayAction { federatedPolicyOverlayId = o.id }}
class="text-xs text-blue-600 hover:underline">View</a>
</td>
</tr>
|]
statusBadge :: Text -> Text
statusBadge s = case s of
"draft" -> "bg-gray-100 text-gray-600"
"active" -> "bg-green-100 text-green-700"
"retired" -> "bg-red-100 text-red-600"
_ -> "bg-gray-100 text-gray-600"

View File

@@ -0,0 +1,27 @@
module Web.View.FederatedPolicyOverlays.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ overlay :: !FederatedPolicyOverlay
, hubs :: ![Hub]
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold mb-6">New Policy Overlay</h1>
{renderForm overlay}
</div>
|]
renderForm :: FederatedPolicyOverlay -> Html
renderForm overlay = formFor overlay [hsx|
{textField #title}
{(textareaField #policyText){ helpText = "Full policy text; once activated this cannot be changed" }}
{(textareaField #notes){ label = "Notes (optional)" }}
{submitButton}
|]

View File

@@ -0,0 +1,76 @@
module Web.View.FederatedPolicyOverlays.PolicyComplianceDashboard where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data PolicyComplianceDashboardView = PolicyComplianceDashboardView
{ overlays :: ![FederatedPolicyOverlay]
, hubs :: ![Hub]
, decisions :: ![DecisionRecord]
, policies :: ![PolicyReference]
}
instance View PolicyComplianceDashboardView where
html PolicyComplianceDashboardView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Policy Compliance Dashboard</h1>
<a href={FederatedPolicyOverlaysAction}
class="text-sm text-gray-500 hover:underline"> All Policies</a>
</div>
{if null overlays
then [hsx|
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
<p class="text-gray-400 text-sm">No active policy overlays.</p>
</div>
|]
else [hsx|
<div class="space-y-4">
{forEach overlays renderOverlayRow}
</div>
|]}
<div class="mt-8 bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-medium text-gray-800 mb-4">Overall Coverage</h2>
<div class="flex items-center gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">{show totalDecisions}</div>
<div class="text-xs text-gray-500 mt-1">Total Decisions</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-green-600">{show coveredDecisions}</div>
<div class="text-xs text-gray-500 mt-1">With Policy Ref</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-indigo-600">{coveragePct}%</div>
<div class="text-xs text-gray-500 mt-1">Coverage</div>
</div>
</div>
</div>
|]
where
decisionIdsWithPolicy = map (.requirementId) policies |> catMaybes |> map show
coveredDecisions = length $ filter (\d -> show d.id `elem` decisionIdsWithPolicy) decisions
totalDecisions = length decisions
coveragePct :: Int
coveragePct = if totalDecisions == 0 then 0
else (coveredDecisions * 100) `div` totalDecisions
renderOverlayRow :: FederatedPolicyOverlay -> Html
renderOverlayRow o = [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-800">{o.title}</h3>
<p class="text-xs text-gray-500 mt-1">
Enforced from: {maybe "" show o.enforcedFrom}
</p>
</div>
<span class="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded font-medium">
active
</span>
</div>
</div>
|]

View File

@@ -0,0 +1,70 @@
module Web.View.FederatedPolicyOverlays.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Web.View.FederatedPolicyOverlays.Index (statusBadge)
data ShowView = ShowView
{ overlay :: !FederatedPolicyOverlay
, hubs :: ![Hub]
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="max-w-3xl">
<div class="flex items-center gap-3 mb-6">
<a href={FederatedPolicyOverlaysAction} class="text-sm text-gray-500 hover:underline">Policies</a>
<span class="text-gray-300">/</span>
<h1 class="text-2xl font-semibold">{overlay.title}</h1>
<span class={statusBadge overlay.status <> " text-sm px-2 py-0.5 rounded font-medium"}>
{overlay.status}
</span>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-6">
<div>
<h2 class="text-sm font-medium text-gray-500 mb-2">Policy Text</h2>
<div class="bg-gray-50 rounded p-4 text-sm text-gray-800 whitespace-pre-wrap">
{overlay.policyText}
</div>
</div>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Enforced From</dt>
<dd>{maybe "" show overlay.enforcedFrom}</dd>
</div>
<div>
<dt class="text-gray-500">Created</dt>
<dd>{show overlay.createdAt}</dd>
</div>
{whenJust overlay.notes \n -> [hsx|
<div class="col-span-2">
<dt class="text-gray-500">Notes</dt>
<dd class="text-gray-700">{n}</dd>
</div>
|]}
</dl>
</div>
<div class="mt-4 flex gap-4">
{if overlay.status == "draft"
then [hsx|
<a href={EditFederatedPolicyOverlayAction { federatedPolicyOverlayId = overlay.id }}
class="text-sm text-blue-600 hover:underline">Edit</a>
<a href={ActivateFederatedPolicyAction { federatedPolicyOverlayId = overlay.id }}
class="text-sm text-green-600 hover:underline">Activate</a>
|]
else mempty}
{if overlay.status == "active"
then [hsx|
<a href={RetireFederatedPolicyAction { federatedPolicyOverlayId = overlay.id }}
class="text-sm text-red-600 hover:underline"
onclick="return confirm('Retire this policy overlay?')">Retire</a>
|]
else mempty}
</div>
</div>
|]

View File

@@ -0,0 +1,28 @@
module Web.View.HubRoutingRules.Edit where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data EditView = EditView
{ rule :: !HubRoutingRule
, hubs :: ![Hub]
}
instance View EditView where
html EditView { .. } = [hsx|
<div class="max-w-lg">
<h1 class="text-2xl font-semibold mb-6">Edit Routing Rule</h1>
{renderForm rule}
</div>
|]
renderForm :: HubRoutingRule -> Html
renderForm rule = formFor rule [hsx|
{(textField #matchCategory){ helpText = "Leave blank to match any category" }}
{(textField #matchWidgetType){ helpText = "Leave blank to match any widget type" }}
{numberField #priority}
{textareaField #notes}
{submitButton}
|]

View File

@@ -0,0 +1,78 @@
module Web.View.HubRoutingRules.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ rules :: ![HubRoutingRule]
, hubs :: ![Hub]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Hub Routing Rules</h1>
<a href={NewHubRoutingRuleAction}
class="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
New Rule
</a>
</div>
{if null rules
then [hsx|<p class="text-sm text-gray-400">No routing rules configured yet.</p>|]
else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Source Target</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Match Category</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Match Widget Type</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Priority</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach rules renderRow}
</tbody>
</table>
</div>
|]}
|]
where
hubName hid = maybe (show hid) (.name) (find (\h -> h.id == hid) hubs)
renderRow :: HubRoutingRule -> Html
renderRow r = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">
{hubName r.sourceHubId} {hubName r.targetHubId}
</td>
<td class="px-4 py-3 text-gray-500">{maybe "any" id r.matchCategory}</td>
<td class="px-4 py-3 text-gray-500">{maybe "any" id r.matchWidgetType}</td>
<td class="px-4 py-3 text-gray-600">{show r.priority}</td>
<td class="px-4 py-3">
<span class={statusBadge r.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{r.status}
</span>
</td>
<td class="px-4 py-3 text-right space-x-3">
<a href={ShowHubRoutingRuleAction { hubRoutingRuleId = r.id }}
class="text-xs text-blue-600 hover:underline">View</a>
{if r.status == "inactive"
then [hsx|<a href={ActivateRoutingRuleAction { hubRoutingRuleId = r.id }}
class="text-xs text-green-600 hover:underline">Activate</a>|]
else [hsx|<a href={DeactivateRoutingRuleAction { hubRoutingRuleId = r.id }}
class="text-xs text-gray-500 hover:underline">Deactivate</a>|]}
</td>
</tr>
|]
statusBadge :: Text -> Text
statusBadge s = case s of
"active" -> "bg-green-100 text-green-700"
"inactive" -> "bg-gray-100 text-gray-500"
_ -> "bg-gray-100 text-gray-600"

View File

@@ -0,0 +1,30 @@
module Web.View.HubRoutingRules.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ rule :: !HubRoutingRule
, hubs :: ![Hub]
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-lg">
<h1 class="text-2xl font-semibold mb-6">New Routing Rule</h1>
{renderForm rule hubs}
</div>
|]
renderForm :: HubRoutingRule -> [Hub] -> Html
renderForm rule hubs = formFor rule [hsx|
{(selectField #sourceHubId hubs){ label = "Source Hub" }}
{(selectField #targetHubId hubs){ label = "Target Hub" }}
{(textField #matchCategory){ helpText = "Leave blank to match any category" }}
{(textField #matchWidgetType){ helpText = "Leave blank to match any widget type" }}
{(numberField #priority){ helpText = "Higher priority rules are evaluated first" }}
{textareaField #notes}
{submitButton}
|]

View File

@@ -0,0 +1,63 @@
module Web.View.HubRoutingRules.RoutedCandidates where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data RoutedCandidatesView = RoutedCandidatesView
{ hub :: !Hub
, candidates :: ![RequirementCandidate]
}
instance View RoutedCandidatesView where
html RoutedCandidatesView { .. } = [hsx|
<div class="flex items-center gap-3 mb-6">
<a href={HubRoutingRulesAction} class="text-sm text-gray-500 hover:underline">Routing Rules</a>
<span class="text-gray-300">/</span>
<h1 class="text-2xl font-semibold">Routed In: {hub.name}</h1>
</div>
<p class="text-sm text-gray-500 mb-4">
Requirement candidates routed to this hub from other hubs.
</p>
{if null candidates
then [hsx|<p class="text-sm text-gray-400">No candidates routed to this hub yet.</p>|]
else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Summary</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Category</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Created</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach candidates renderRow}
</tbody>
</table>
</div>
|]}
|]
where
renderRow :: RequirementCandidate -> Html
renderRow c = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-800">{c.summary}</td>
<td class="px-4 py-3 text-gray-500">{c.category}</td>
<td class="px-4 py-3">
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded font-medium">
{c.status}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-400">{show c.createdAt}</td>
<td class="px-4 py-3 text-right">
<a href={ShowRequirementCandidateAction { requirementCandidateId = c.id }}
class="text-xs text-blue-600 hover:underline">View</a>
</td>
</tr>
|]

View File

@@ -0,0 +1,72 @@
module Web.View.HubRoutingRules.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Web.View.HubRoutingRules.Index (statusBadge)
data ShowView = ShowView
{ rule :: !HubRoutingRule
, sourceHub :: !Hub
, targetHub :: !Hub
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="max-w-2xl">
<div class="flex items-center gap-3 mb-6">
<a href={HubRoutingRulesAction} class="text-sm text-gray-500 hover:underline">Routing Rules</a>
<span class="text-gray-300">/</span>
<h1 class="text-2xl font-semibold">Routing Rule</h1>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<div class="flex items-center gap-3">
<span class="text-lg font-medium text-gray-800">
{sourceHub.name} {targetHub.name}
</span>
<span class={statusBadge rule.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{rule.status}
</span>
</div>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Match Category</dt>
<dd class="font-medium">{maybe "any" id rule.matchCategory}</dd>
</div>
<div>
<dt class="text-gray-500">Match Widget Type</dt>
<dd class="font-medium">{maybe "any" id rule.matchWidgetType}</dd>
</div>
<div>
<dt class="text-gray-500">Priority</dt>
<dd>{show rule.priority}</dd>
</div>
<div>
<dt class="text-gray-500">Created</dt>
<dd>{show rule.createdAt}</dd>
</div>
{whenJust rule.notes \n -> [hsx|
<div class="col-span-2">
<dt class="text-gray-500">Notes</dt>
<dd class="text-gray-700">{n}</dd>
</div>
|]}
</dl>
</div>
<div class="mt-4 flex gap-4">
<a href={EditHubRoutingRuleAction { hubRoutingRuleId = rule.id }}
class="text-sm text-blue-600 hover:underline">Edit</a>
{if rule.status == "inactive"
then [hsx|<a href={ActivateRoutingRuleAction { hubRoutingRuleId = rule.id }}
class="text-sm text-green-600 hover:underline">Activate</a>|]
else [hsx|<a href={DeactivateRoutingRuleAction { hubRoutingRuleId = rule.id }}
class="text-sm text-gray-500 hover:underline">Deactivate</a>|]}
<a href={RoutedCandidatesAction { hubId = targetHub.id }}
class="text-sm text-indigo-600 hover:underline">Routed Candidates </a>
</div>
</div>
|]

View File

@@ -0,0 +1,81 @@
module Web.View.StewardshipRoles.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ roles :: ![StewardshipRole]
, hubs :: ![Hub]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Stewardship Roles</h1>
<a href={NewStewardshipRoleAction}
class="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
Grant Role
</a>
</div>
{if null roles
then [hsx|<p class="text-sm text-gray-400">No stewardship roles assigned yet.</p>|]
else [hsx|
<div class="space-y-6">
{forEach hubGroups renderHubGroup}
</div>
|]}
|]
where
hubName hid = maybe (show hid) (.name) (find (\h -> h.id == hid) hubs)
hubGroups = groupByHub hubs roles
groupByHub :: [Hub] -> [StewardshipRole] -> [(Hub, [StewardshipRole])]
groupByHub hs rs =
[ (h, filter (\r -> r.hubId == h.id) rs)
| h <- hs
, any (\r -> r.hubId == h.id) rs
]
renderHubGroup :: (Hub, [StewardshipRole]) -> Html
renderHubGroup (hub, hubRoles) = [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 class="font-medium text-gray-700">{hub.name}</h2>
</div>
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left px-4 py-2 font-medium text-gray-600">Role</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Assigned To</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Granted</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Status</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach hubRoles renderRoleRow}
</tbody>
</table>
</div>
|]
renderRoleRow :: StewardshipRole -> Html
renderRoleRow r = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">{r.roleName}</td>
<td class="px-4 py-3 text-gray-600">{r.assignedTo}</td>
<td class="px-4 py-3 text-xs text-gray-400">{show r.grantedAt}</td>
<td class="px-4 py-3">
{if isNothing r.revokedAt
then [hsx|<span class="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded font-medium">active</span>|]
else [hsx|<span class="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-medium">revoked</span>|]}
</td>
<td class="px-4 py-3 text-right">
<a href={ShowStewardshipRoleAction { stewardshipRoleId = r.id }}
class="text-xs text-blue-600 hover:underline">View</a>
</td>
</tr>
|]

View File

@@ -0,0 +1,28 @@
module Web.View.StewardshipRoles.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ role :: !StewardshipRole
, hubs :: ![Hub]
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-lg">
<h1 class="text-2xl font-semibold mb-6">Grant Stewardship Role</h1>
{renderForm role hubs}
</div>
|]
renderForm :: StewardshipRole -> [Hub] -> Html
renderForm role hubs = formFor role [hsx|
{(selectField #hubId hubs){ label = "Hub" }}
{(textField #roleName){ helpText = "e.g. Hub Lead, Policy Steward, Triage Owner" }}
{(textField #assignedTo){ helpText = "Person name or identifier" }}
{(textareaField #notes){ label = "Notes (optional)" }}
{submitButton}
|]

View File

@@ -0,0 +1,64 @@
module Web.View.StewardshipRoles.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data ShowView = ShowView
{ role :: !StewardshipRole
, hub :: !Hub
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="max-w-xl">
<div class="flex items-center gap-3 mb-6">
<a href={StewardshipRolesAction} class="text-sm text-gray-500 hover:underline">Stewards</a>
<span class="text-gray-300">/</span>
<h1 class="text-2xl font-semibold">{role.roleName}</h1>
{if isNothing role.revokedAt
then [hsx|<span class="text-sm bg-green-100 text-green-700 px-2 py-0.5 rounded font-medium">active</span>|]
else [hsx|<span class="text-sm bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-medium">revoked</span>|]}
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Hub</dt>
<dd class="font-medium">{hub.name}</dd>
</div>
<div>
<dt class="text-gray-500">Assigned To</dt>
<dd class="font-medium">{role.assignedTo}</dd>
</div>
<div>
<dt class="text-gray-500">Granted At</dt>
<dd>{show role.grantedAt}</dd>
</div>
<div>
<dt class="text-gray-500">Revoked At</dt>
<dd>{maybe "" show role.revokedAt}</dd>
</div>
{whenJust role.notes \n -> [hsx|
<div class="col-span-2">
<dt class="text-gray-500">Notes</dt>
<dd class="text-gray-700">{n}</dd>
</div>
|]}
</dl>
</div>
{if isNothing role.revokedAt
then [hsx|
<div class="mt-4">
<a href={RevokeRoleAction { stewardshipRoleId = role.id }}
class="text-sm text-red-600 hover:underline"
onclick="return confirm('Revoke this stewardship role?')">
Revoke Role
</a>
</div>
|]
else mempty}
</div>
|]

View File

@@ -0,0 +1,32 @@
module Web.View.WidgetOwnerships.Edit where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data EditView = EditView
{ ownership :: !WidgetOwnership
, widgets :: ![Widget]
, hubs :: ![Hub]
}
instance View EditView where
html EditView { .. } = [hsx|
<div class="max-w-lg">
<h1 class="text-2xl font-semibold mb-6">Edit Ownership</h1>
{renderForm ownership hubs}
</div>
|]
renderForm :: WidgetOwnership -> [Hub] -> Html
renderForm ownership hubs = formFor ownership [hsx|
{(selectField #stewardHubId hubs){ label = "Steward Hub (optional)" }}
{(selectField #ownershipType ownershipTypes){ label = "Ownership Type" }}
{dateTimeField #effectiveUntil}
{textareaField #notes}
{submitButton}
|]
where
ownershipTypes :: [(Text, Text)]
ownershipTypes = [("local","local"), ("delegated","delegated"), ("global","global")]

View File

@@ -0,0 +1,76 @@
module Web.View.WidgetOwnerships.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ ownerships :: ![WidgetOwnership]
, widgets :: ![Widget]
, hubs :: ![Hub]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Widget Ownerships</h1>
<a href={NewWidgetOwnershipAction}
class="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
Assign Ownership
</a>
</div>
{if null ownerships
then [hsx|<p class="text-sm text-gray-400">No ownership records yet.</p>|]
else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Widget</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Owner Hub</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Steward Hub</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Type</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Effective From</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach ownerships renderRow}
</tbody>
</table>
</div>
|]}
|]
where
widgetName wid = maybe (show wid) (.name) (find (\w -> w.id == wid) widgets)
hubName hid = maybe "" (.name) (find (\h -> h.id == hid) hubs)
renderRow :: WidgetOwnership -> Html
renderRow o = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">{widgetName o.widgetId}</td>
<td class="px-4 py-3 text-gray-600">{hubName o.ownerHubId}</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{maybe "" hubName o.stewardHubId}
</td>
<td class="px-4 py-3">
<span class={typeBadge o.ownershipType <> " text-xs px-2 py-0.5 rounded font-medium"}>
{o.ownershipType}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-400">{show o.effectiveFrom}</td>
<td class="px-4 py-3 text-right">
<a href={ShowWidgetOwnershipAction { widgetOwnershipId = o.id }}
class="text-xs text-blue-600 hover:underline">View</a>
</td>
</tr>
|]
typeBadge :: Text -> Text
typeBadge t = case t of
"local" -> "bg-gray-100 text-gray-700"
"delegated" -> "bg-blue-100 text-blue-700"
"global" -> "bg-purple-100 text-purple-700"
_ -> "bg-gray-100 text-gray-600"

View File

@@ -0,0 +1,35 @@
module Web.View.WidgetOwnerships.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ ownership :: !WidgetOwnership
, widgets :: ![Widget]
, hubs :: ![Hub]
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="max-w-lg">
<h1 class="text-2xl font-semibold mb-6">Assign Ownership</h1>
{renderForm ownership widgets hubs}
</div>
|]
renderForm :: WidgetOwnership -> [Widget] -> [Hub] -> Html
renderForm ownership widgets hubs = formFor ownership [hsx|
{(selectField #widgetId widgets) { label = "Widget" }}
{(selectField #ownerHubId hubs) { label = "Owner Hub" }}
{(selectField #stewardHubId hubs){ label = "Steward Hub (optional)" }}
{(selectField #ownershipType ownershipTypes){ label = "Ownership Type" }}
{dateTimeField #effectiveFrom}
{dateTimeField #effectiveUntil}
{textareaField #notes}
{submitButton}
|]
where
ownershipTypes :: [(Text, Text)]
ownershipTypes = [("local","local"), ("delegated","delegated"), ("global","global")]

View File

@@ -0,0 +1,74 @@
module Web.View.WidgetOwnerships.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Web.View.WidgetOwnerships.Index (typeBadge)
data ShowView = ShowView
{ ownership :: !WidgetOwnership
, widget :: !Widget
, ownerHub :: !Hub
, mStewardHub :: !(Maybe Hub)
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="max-w-2xl">
<div class="flex items-center gap-3 mb-6">
<a href={WidgetOwnershipsAction} class="text-sm text-gray-500 hover:underline">Ownerships</a>
<span class="text-gray-300">/</span>
<h1 class="text-2xl font-semibold">Ownership Record</h1>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<div class="flex items-center gap-3">
<span class={typeBadge ownership.ownershipType <> " text-sm px-3 py-1 rounded-full font-medium"}>
{ownership.ownershipType}
</span>
</div>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Widget</dt>
<dd class="font-medium">
<a href={ShowWidgetAction { widgetId = widget.id }}
class="text-blue-600 hover:underline">{widget.name}</a>
</dd>
</div>
<div>
<dt class="text-gray-500">Owner Hub</dt>
<dd class="font-medium">{ownerHub.name}</dd>
</div>
<div>
<dt class="text-gray-500">Steward Hub</dt>
<dd class="font-medium">{maybe "Same as owner" (.name) mStewardHub}</dd>
</div>
<div>
<dt class="text-gray-500">Effective From</dt>
<dd>{show ownership.effectiveFrom}</dd>
</div>
<div>
<dt class="text-gray-500">Effective Until</dt>
<dd>{maybe "" show ownership.effectiveUntil}</dd>
</div>
<div>
<dt class="text-gray-500">Created</dt>
<dd>{show ownership.createdAt}</dd>
</div>
{whenJust ownership.notes \n -> [hsx|
<div class="col-span-2">
<dt class="text-gray-500">Notes</dt>
<dd class="text-gray-700">{n}</dd>
</div>
|]}
</dl>
</div>
<div class="mt-4">
<a href={EditWidgetOwnershipAction { widgetOwnershipId = ownership.id }}
class="text-sm text-blue-600 hover:underline">Edit</a>
</div>
</div>
|]

126
docs/phase8-summary.md Normal file
View File

@@ -0,0 +1,126 @@
# Phase 8 Summary — Federated Hub Maturity
Phase 8 completes the IHF v0.1 specification. It introduces the governance
structures needed when multiple teams, hubs, and policies must coexist at
organisational scale.
## What Was Built
### Delegated Ownership (`WidgetOwnership`)
Every widget can now carry an explicit ownership record: `local` (owned by its
hub), `delegated` (steward hub differs from owner hub), or `global` (org-wide).
Ownership records are append-only audit artefacts — `effective_until` signals
expiry, but records are never deleted.
The ownership badge appears on the widget show page (colour-coded: local=gray,
delegated=blue, global=purple).
### Inter-Hub Requirement Routing (`HubRoutingRule`)
A priority-ordered rule engine routes `RequirementCandidate` records across hub
boundaries. When a candidate is created, the engine finds the highest-priority
active rule whose `match_category` and `match_widget_type` match (null = any)
and sets `routed_to_hub_id` on the candidate.
`RouteNowAction` allows manual re-evaluation. `RoutedCandidatesAction { hubId }`
shows all candidates forwarded to a given hub from any source hub.
### Federated Policy Overlays (`FederatedPolicyOverlay`)
Org-wide governance policies applied across selected hubs (or all hubs via
`applies_to_hubs = []`). Overlays follow a `draft → active → retired` lifecycle.
**Immutability pattern:** once activated, an overlay cannot be edited. A new
overlay must be created to supersede the old one. The old overlay remains
readable for audit. This mirrors the Phase 6 `EnvelopeEmissionContract`
immutability pattern.
The Policy Compliance Dashboard shows coverage metrics: decisions referencing
at least one `PolicyReference` as a percentage of total in-scope decisions.
### Stewardship Roles (`StewardshipRole`)
Named governance roles assigned to hubs (e.g. "Hub Lead", "Policy Steward",
"Triage Owner"). Roles have `granted_at` and `revoked_at` timestamps.
Contextual steward queries use the point-in-time pattern:
`granted_at ≤ T AND (revoked_at IS NULL OR revoked_at > T)`.
No edits — create a new record to replace a role.
### Archival and Lineage Inspection (`ArchiveRecord`, `is_archived`)
**Soft-delete pattern:** `is_archived BOOLEAN NOT NULL DEFAULT FALSE` on
`widgets`. Active queries filter with `filterWhere (#isArchived, False)`.
The widget row and all related records are preserved.
`ArchiveWidgetAction` sets the flag and creates an `ArchiveRecord` (subject_type,
subject_id, reason, archived_by, lineage_ref).
`LineageInspectorAction { widgetId }` renders the full IHF traceability chain
in a single read-only timeline:
`Widget → InteractionEvents → Annotations → RequirementCandidates
→ Requirements → DecisionRecords → DeploymentRecords → OutcomeSignals`
plus any `ArchiveRecord` for the widget.
### Federated Governance Dashboard
`FederatedGovernanceDashboardAction` (autoRefresh, five panels):
| Panel | Metric |
|-------|--------|
| 1 — Ownership | % of widgets with ownership records; breakdown by type |
| 2 — Routing | Active rule count; candidates routed cross-hub in 30 days |
| 3 — Policy compliance | Active overlays; % decisions with policy reference |
| 4 — Stewardship | Hubs with ≥1 active steward; hubs with no stewards |
| 5 — Archive activity | Artifact counts archived in last 90 days by subject type |
## Schema Changes
```sql
widget_ownerships -- delegated ownership records
hub_routing_rules -- inter-hub routing logic
requirement_candidates.routed_to_hub_id -- routing destination (nullable)
federated_policy_overlays -- immutable org-wide policies
stewardship_roles -- point-in-time governance roles
archive_records -- soft-delete audit trail
widgets.is_archived -- soft-delete flag
```
Migration: `Application/Migration/1743638400-ihf-phase8-federated-hub-maturity.sql`
## Routing Engine
`Application/Helper/RoutingEngine.hs``applyRoutingRules`:
```haskell
ruleMatches category mWidgetType rule =
categoryMatch && widgetTypeMatch
where
categoryMatch = isNothing rule.matchCategory || rule.matchCategory == Just category
widgetTypeMatch = isNothing rule.matchWidgetType ||
(isJust mWidgetType && rule.matchWidgetType == mWidgetType)
```
Null-inclusive matching: a rule with no `match_category` fires on any category.
Only the highest-priority active matching rule fires per candidate.
## Known Limitations
- `applies_to_hubs` on `FederatedPolicyOverlay` is stored as JSONB; Phase 8
does not enforce referential integrity between hub IDs in this column and the
`hubs` table. A future phase could validate on activation.
- `LineageInspectorAction` is widget-scoped. A fully generic artefact-scoped
lineage inspector (for decisions, deployments, etc.) is a Phase 9+ feature.
- Routing is evaluated on candidate creation and on manual `RouteNowAction`.
There is no background re-evaluation if rules change after candidates exist.
- Ownership records have no uniqueness constraint — multiple active ownerships
per widget are possible. The latest `effective_from` record is authoritative
by convention.
## IHF v0.1 Status
All eight phases of `specs/InteractionHubFrameworkSpecification_v0.1.md` are
now implemented in the reference IHP application. See
`specs/InteractionHubFrameworkSpecification_v0.2.md` for the planned Phases 912
roadmap (External API, Marketplace, AI Federation, Platform Memory).

View File

@@ -4,7 +4,7 @@ type: workplan
title: "IHF Phase 8 — Federated Hub Maturity"
domain: inter_hub
repo: inter-hub
status: todo
status: done
owner: custodian
topic_slug: inter_hub
created: "2026-03-29"
@@ -62,7 +62,7 @@ Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 8,
```task
id: IHUB-WP-0008-T01
status: todo
status: done
priority: high
state_hub_task_id: "5c5315b7-98ff-45dc-8eef-a5df83e18ea2"
```
@@ -184,7 +184,7 @@ CREATE INDEX widgets_is_archived_idx ON widgets (is_archived)
```task
id: IHUB-WP-0008-T02
status: todo
status: done
priority: high
state_hub_task_id: "4d12c8e2-7b8a-4da7-a37d-0663453a3e43"
```
@@ -216,7 +216,7 @@ renders the badge; hub show page lists owned/stewarded widgets.
```task
id: IHUB-WP-0008-T03
status: todo
status: done
priority: high
state_hub_task_id: "54597bea-bd1f-41ab-bb50-f2f19dc45c01"
```
@@ -253,7 +253,7 @@ receives `routed_to_hub_id`; `RoutedCandidatesAction` shows it; manual
```task
id: IHUB-WP-0008-T04
status: todo
status: done
priority: high
state_hub_task_id: "df2fcdb1-657f-49d1-b340-79d4f55a9088"
```
@@ -286,7 +286,7 @@ overlays.
```task
id: IHUB-WP-0008-T05
status: todo
status: done
priority: medium
state_hub_task_id: "490f37e1-44b2-4667-8213-4498121aaa55"
```
@@ -316,7 +316,7 @@ stewards; decision show page shows contextual stewards; ops board panel renders.
```task
id: IHUB-WP-0008-T06
status: todo
status: done
priority: medium
state_hub_task_id: "4b59d882-b690-4e14-8460-614bd114ce7a"
```
@@ -347,7 +347,7 @@ flag filters it from active queries; lineage inspector renders the full chain.
```task
id: IHUB-WP-0008-T07
status: todo
status: done
priority: medium
state_hub_task_id: "0c2f6b98-41a5-4876-8bcc-07af08acaf77"
```
@@ -375,7 +375,7 @@ all counts are correct against test fixtures.
```task
id: IHUB-WP-0008-T08
status: todo
status: done
priority: high
state_hub_task_id: "422cae8f-5dc6-4393-b78a-77169b00da8a"
```