diff --git a/Application/Helper/RoutingEngine.hs b/Application/Helper/RoutingEngine.hs new file mode 100644 index 0000000..18757a9 --- /dev/null +++ b/Application/Helper/RoutingEngine.hs @@ -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) diff --git a/Application/Migration/1743638400-ihf-phase8-federated-hub-maturity.sql b/Application/Migration/1743638400-ihf-phase8-federated-hub-maturity.sql new file mode 100644 index 0000000..be44fab --- /dev/null +++ b/Application/Migration/1743638400-ihf-phase8-federated-hub-maturity.sql @@ -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; diff --git a/Application/Schema.sql b/Application/Schema.sql index bf7e8b0..00e0259 100644 --- a/Application/Schema.sql +++ b/Application/Schema.sql @@ -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; diff --git a/SCOPE.md b/SCOPE.md index ccc4331..56d935c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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 --- diff --git a/Test/Integration.hs b/Test/Integration.hs index 7b79695..5b469f4 100644 --- a/Test/Integration.hs +++ b/Test/Integration.hs @@ -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 diff --git a/Web/Controller/ArchiveRecords.hs b/Web/Controller/ArchiveRecords.hs new file mode 100644 index 0000000..21c46e4 --- /dev/null +++ b/Web/Controller/ArchiveRecords.hs @@ -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 } diff --git a/Web/Controller/FederatedGovernance.hs b/Web/Controller/FederatedGovernance.hs new file mode 100644 index 0000000..b77bd32 --- /dev/null +++ b/Web/Controller/FederatedGovernance.hs @@ -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 } diff --git a/Web/Controller/FederatedPolicyOverlays.hs b/Web/Controller/FederatedPolicyOverlays.hs new file mode 100644 index 0000000..48ae4bd --- /dev/null +++ b/Web/Controller/FederatedPolicyOverlays.hs @@ -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 } diff --git a/Web/Controller/HubRoutingRules.hs b/Web/Controller/HubRoutingRules.hs new file mode 100644 index 0000000..13c69ac --- /dev/null +++ b/Web/Controller/HubRoutingRules.hs @@ -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 } diff --git a/Web/Controller/StewardshipRoles.hs b/Web/Controller/StewardshipRoles.hs new file mode 100644 index 0000000..f5d5701 --- /dev/null +++ b/Web/Controller/StewardshipRoles.hs @@ -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 diff --git a/Web/Controller/WidgetOwnerships.hs b/Web/Controller/WidgetOwnerships.hs new file mode 100644 index 0000000..97f5bd6 --- /dev/null +++ b/Web/Controller/WidgetOwnerships.hs @@ -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 } diff --git a/Web/FrontController.hs b/Web/FrontController.hs index 835c77e..ee4dedd 100644 --- a/Web/FrontController.hs +++ b/Web/FrontController.hs @@ -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| Adapters Propagations Ops Review + Federation + Policies + Archive
Sign out
diff --git a/Web/Routes.hs b/Web/Routes.hs index 7108458..37c9b22 100644 --- a/Web/Routes.hs +++ b/Web/Routes.hs @@ -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 diff --git a/Web/Types.hs b/Web/Types.hs index b04f845..ccb8943 100644 --- a/Web/Types.hs +++ b/Web/Types.hs @@ -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 diff --git a/Web/View/ArchiveRecords/Index.hs b/Web/View/ArchiveRecords/Index.hs new file mode 100644 index 0000000..ea13cae --- /dev/null +++ b/Web/View/ArchiveRecords/Index.hs @@ -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| +
+

Archive Records

+
+ + {if null records + then [hsx|

No archived artifacts yet.

|] + else [hsx| +
+ + + + + + + + + + + + + {forEach records renderRow} + +
Subject TypeSubject IDReasonArchived ByArchived At
+
+ |]} + |] + where + renderRow :: ArchiveRecord -> Html + renderRow r = [hsx| + + + + {r.subjectType} + + + {show r.subjectId} + {r.reason} + {r.archivedBy} + {show r.archivedAt} + + View + + + |] diff --git a/Web/View/ArchiveRecords/LineageInspector.hs b/Web/View/ArchiveRecords/LineageInspector.hs new file mode 100644 index 0000000..c3718aa --- /dev/null +++ b/Web/View/ArchiveRecords/LineageInspector.hs @@ -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| +
+
+ {widget.name} + / +

Lineage Inspector

+ {if widget.isArchived + then [hsx|Archived|] + else mempty} +
+

Full traceability chain for this widget.

+ +
+ {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} +
+ + {whenJust mArchive \archive -> [hsx| +
+

Archive Record

+
+
Archived At
{show archive.archivedAt}
+
Archived By
{archive.archivedBy}
+
Reason
{archive.reason}
+
+
+ |]} + +
+

Recent Interaction Events

+ {if null events + then [hsx|

No events recorded.

|] + else [hsx| +
+ + + + + + + + + {forEach events renderEventRow} + +
Event TypeOccurred At
+
+ |]} +
+
+ |] + where + renderChainStep :: Text -> Text -> Int -> Maybe a -> Html + renderChainStep stepNum label count mLink = [hsx| +
+
+ {stepNum} +
+
+ {label} + {show count} +
+
+ |] + + renderEventRow :: InteractionEvent -> Html + renderEventRow e = [hsx| + + {e.eventType} + {show e.occurredAt} + + |] diff --git a/Web/View/ArchiveRecords/Show.hs b/Web/View/ArchiveRecords/Show.hs new file mode 100644 index 0000000..e90e871 --- /dev/null +++ b/Web/View/ArchiveRecords/Show.hs @@ -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| +
+
+ Archive + / +

Archive Record

+
+ +
+
+
+
Subject Type
+
{record.subjectType}
+
+
+
Subject ID
+
{show record.subjectId}
+
+
+
Archived At
+
{show record.archivedAt}
+
+
+
Archived By
+
{record.archivedBy}
+
+
+
Reason
+
{record.reason}
+
+ {whenJust record.lineageRef \ref -> [hsx| +
+
Lineage Reference
+
{ref}
+
+ |]} +
+
+ + {if record.subjectType == "Widget" + then [hsx| +
+ View Lineage → +
+ |] + else mempty} +
+ |] diff --git a/Web/View/FederatedGovernance/Dashboard.hs b/Web/View/FederatedGovernance/Dashboard.hs new file mode 100644 index 0000000..cf7ea86 --- /dev/null +++ b/Web/View/FederatedGovernance/Dashboard.hs @@ -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| +
+

Federated Governance

+ Policy Compliance → +
+ +
+ {panel1Ownership} + {panel2Routing} + {panel3PolicyCompliance} + {panel4Stewardship} + {panel5Archive} +
+ |] + 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| +
+
+

Ownership Coverage

+ View all → +
+
+
+
{show ownedCount}
+
of {show totalWidgets} widgets owned
+
+
+
{show ownershipPct}%
+
coverage
+
+
+
+ + local: {show localCount} + + + delegated: {show delegatedCount} + + + global: {show globalCount} + +
+
+ |] + + -- ── Panel 2: Routing activity ───────────────────────────────────── + activeRulesCount = length rules + routedCount = length routedCandidates + hubName hid = maybe (show hid) (.name) (find (\h -> h.id == hid) hubs) + + panel2Routing = [hsx| +
+
+

Routing Activity

+ Rules → +
+
+
+
{show activeRulesCount}
+
active rules
+
+
+
{show routedCount}
+
routed (30 days)
+
+
+ {if null rules + then [hsx|

No active routing rules.

|] + else [hsx| +
+ {forEach (take 5 rules) renderRuleRow} +
+ |]} +
+ |] + + renderRuleRow :: HubRoutingRule -> Html + renderRuleRow r = [hsx| +
+ {hubName r.sourceHubId} + + {hubName r.targetHubId} + {maybe mempty (\c -> [hsx|({c})|]) r.matchCategory} +
+ |] + + -- ── 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| +
+
+

Policy Compliance

+ Dashboard → +
+
+
+
{show activeOverlaysCount}
+
active overlays
+
+
+
{show policyPct}%
+
decision coverage
+
+
+ {if null overlays + then [hsx|

No active policy overlays.

|] + else [hsx| +
+ {forEach overlays \o -> [hsx| +
{o.title}
+ |]} +
+ |]} +
+ |] + + -- ── 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| +
+
+

Stewardship Coverage

+ Roles → +
+
+
+
{show stewardedCount}
+
of {show totalHubs} hubs stewarded
+
+
+
{show (length hubsWithNoSteward)}
+
hubs unassigned
+
+
+ {if null hubsWithNoSteward + then [hsx|

All hubs have active stewards.

|] + else [hsx| +
+

Hubs without stewards:

+
+ {forEach hubsWithNoSteward \h -> [hsx| + + {h.name} + + |]} +
+
+ |]} +
+ |] + + -- ── 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| +
+
+

Archive Activity (90 days)

+ All records → +
+ {if null recentArchives + then [hsx|

No artifacts archived in the last 90 days.

|] + else [hsx| +
+
{show (length recentArchives)}
+
total archived artifacts
+
+
+ {forEach archiveByType \(typ, cnt) -> [hsx| + + {typ}: {show cnt} + + |]} +
+ |]} +
+ |] diff --git a/Web/View/FederatedPolicyOverlays/Edit.hs b/Web/View/FederatedPolicyOverlays/Edit.hs new file mode 100644 index 0000000..346656a --- /dev/null +++ b/Web/View/FederatedPolicyOverlays/Edit.hs @@ -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| +
+

Edit Policy Overlay

+

+ Only draft overlays can be edited. Once activated, this policy becomes immutable. +

+ {renderForm overlay} +
+ |] + +renderForm :: FederatedPolicyOverlay -> Html +renderForm overlay = formFor overlay [hsx| + {textField #title} + {textareaField #policyText} + {(textareaField #notes){ label = "Notes (optional)" }} + {submitButton} +|] diff --git a/Web/View/FederatedPolicyOverlays/Index.hs b/Web/View/FederatedPolicyOverlays/Index.hs new file mode 100644 index 0000000..a404cb7 --- /dev/null +++ b/Web/View/FederatedPolicyOverlays/Index.hs @@ -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| +
+

Federated Policy Overlays

+
+ + Compliance Dashboard + + + New Overlay + +
+
+ + {if null overlays + then [hsx|

No policy overlays yet.

|] + else [hsx| +
+ + + + + + + + + + + + {forEach overlays renderRow} + +
TitleStatusEnforced FromCreated
+
+ |]} + |] + where + renderRow :: FederatedPolicyOverlay -> Html + renderRow o = [hsx| + + {o.title} + + " text-xs px-2 py-0.5 rounded font-medium"}> + {o.status} + + + {maybe "–" show o.enforcedFrom} + {show o.createdAt} + + View + + + |] + +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" diff --git a/Web/View/FederatedPolicyOverlays/New.hs b/Web/View/FederatedPolicyOverlays/New.hs new file mode 100644 index 0000000..bd4ff81 --- /dev/null +++ b/Web/View/FederatedPolicyOverlays/New.hs @@ -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| +
+

New Policy Overlay

+ {renderForm overlay} +
+ |] + +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} +|] diff --git a/Web/View/FederatedPolicyOverlays/PolicyComplianceDashboard.hs b/Web/View/FederatedPolicyOverlays/PolicyComplianceDashboard.hs new file mode 100644 index 0000000..deb2b6a --- /dev/null +++ b/Web/View/FederatedPolicyOverlays/PolicyComplianceDashboard.hs @@ -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| +
+

Policy Compliance Dashboard

+ ← All Policies +
+ + {if null overlays + then [hsx| +
+

No active policy overlays.

+
+ |] + else [hsx| +
+ {forEach overlays renderOverlayRow} +
+ |]} + +
+

Overall Coverage

+
+
+
{show totalDecisions}
+
Total Decisions
+
+
+
{show coveredDecisions}
+
With Policy Ref
+
+
+
{coveragePct}%
+
Coverage
+
+
+
+ |] + 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| +
+
+
+

{o.title}

+

+ Enforced from: {maybe "–" show o.enforcedFrom} +

+
+ + active + +
+
+ |] diff --git a/Web/View/FederatedPolicyOverlays/Show.hs b/Web/View/FederatedPolicyOverlays/Show.hs new file mode 100644 index 0000000..fe3f3c6 --- /dev/null +++ b/Web/View/FederatedPolicyOverlays/Show.hs @@ -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| +
+
+ Policies + / +

{overlay.title}

+ " text-sm px-2 py-0.5 rounded font-medium"}> + {overlay.status} + +
+ +
+
+

Policy Text

+
+ {overlay.policyText} +
+
+ +
+
+
Enforced From
+
{maybe "–" show overlay.enforcedFrom}
+
+
+
Created
+
{show overlay.createdAt}
+
+ {whenJust overlay.notes \n -> [hsx| +
+
Notes
+
{n}
+
+ |]} +
+
+ +
+ {if overlay.status == "draft" + then [hsx| + Edit + Activate + |] + else mempty} + {if overlay.status == "active" + then [hsx| + Retire + |] + else mempty} +
+
+ |] diff --git a/Web/View/HubRoutingRules/Edit.hs b/Web/View/HubRoutingRules/Edit.hs new file mode 100644 index 0000000..9f483ce --- /dev/null +++ b/Web/View/HubRoutingRules/Edit.hs @@ -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| +
+

Edit Routing Rule

+ {renderForm rule} +
+ |] + +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} +|] diff --git a/Web/View/HubRoutingRules/Index.hs b/Web/View/HubRoutingRules/Index.hs new file mode 100644 index 0000000..28bbf86 --- /dev/null +++ b/Web/View/HubRoutingRules/Index.hs @@ -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| +
+

Hub Routing Rules

+ + New Rule + +
+ + {if null rules + then [hsx|

No routing rules configured yet.

|] + else [hsx| +
+ + + + + + + + + + + + + {forEach rules renderRow} + +
Source → TargetMatch CategoryMatch Widget TypePriorityStatus
+
+ |]} + |] + where + hubName hid = maybe (show hid) (.name) (find (\h -> h.id == hid) hubs) + + renderRow :: HubRoutingRule -> Html + renderRow r = [hsx| + + + {hubName r.sourceHubId} → {hubName r.targetHubId} + + {maybe "any" id r.matchCategory} + {maybe "any" id r.matchWidgetType} + {show r.priority} + + " text-xs px-2 py-0.5 rounded font-medium"}> + {r.status} + + + + View + {if r.status == "inactive" + then [hsx|Activate|] + else [hsx|Deactivate|]} + + + |] + +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" diff --git a/Web/View/HubRoutingRules/New.hs b/Web/View/HubRoutingRules/New.hs new file mode 100644 index 0000000..666581f --- /dev/null +++ b/Web/View/HubRoutingRules/New.hs @@ -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| +
+

New Routing Rule

+ {renderForm rule hubs} +
+ |] + +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} +|] diff --git a/Web/View/HubRoutingRules/RoutedCandidates.hs b/Web/View/HubRoutingRules/RoutedCandidates.hs new file mode 100644 index 0000000..7429127 --- /dev/null +++ b/Web/View/HubRoutingRules/RoutedCandidates.hs @@ -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| +
+ Routing Rules + / +

Routed In: {hub.name}

+
+ +

+ Requirement candidates routed to this hub from other hubs. +

+ + {if null candidates + then [hsx|

No candidates routed to this hub yet.

|] + else [hsx| +
+ + + + + + + + + + + + {forEach candidates renderRow} + +
SummaryCategoryStatusCreated
+
+ |]} + |] + where + renderRow :: RequirementCandidate -> Html + renderRow c = [hsx| + + {c.summary} + {c.category} + + + {c.status} + + + {show c.createdAt} + + View + + + |] diff --git a/Web/View/HubRoutingRules/Show.hs b/Web/View/HubRoutingRules/Show.hs new file mode 100644 index 0000000..fd48f21 --- /dev/null +++ b/Web/View/HubRoutingRules/Show.hs @@ -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| +
+
+ Routing Rules + / +

Routing Rule

+
+ +
+
+ + {sourceHub.name} → {targetHub.name} + + " text-xs px-2 py-0.5 rounded font-medium"}> + {rule.status} + +
+ +
+
+
Match Category
+
{maybe "any" id rule.matchCategory}
+
+
+
Match Widget Type
+
{maybe "any" id rule.matchWidgetType}
+
+
+
Priority
+
{show rule.priority}
+
+
+
Created
+
{show rule.createdAt}
+
+ {whenJust rule.notes \n -> [hsx| +
+
Notes
+
{n}
+
+ |]} +
+
+ +
+ Edit + {if rule.status == "inactive" + then [hsx|Activate|] + else [hsx|Deactivate|]} + Routed Candidates → +
+
+ |] diff --git a/Web/View/StewardshipRoles/Index.hs b/Web/View/StewardshipRoles/Index.hs new file mode 100644 index 0000000..bccd744 --- /dev/null +++ b/Web/View/StewardshipRoles/Index.hs @@ -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| +
+

Stewardship Roles

+ + Grant Role + +
+ + {if null roles + then [hsx|

No stewardship roles assigned yet.

|] + else [hsx| +
+ {forEach hubGroups renderHubGroup} +
+ |]} + |] + 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| +
+
+

{hub.name}

+
+ + + + + + + + + + + + {forEach hubRoles renderRoleRow} + +
RoleAssigned ToGrantedStatus
+
+ |] + + renderRoleRow :: StewardshipRole -> Html + renderRoleRow r = [hsx| + + {r.roleName} + {r.assignedTo} + {show r.grantedAt} + + {if isNothing r.revokedAt + then [hsx|active|] + else [hsx|revoked|]} + + + View + + + |] diff --git a/Web/View/StewardshipRoles/New.hs b/Web/View/StewardshipRoles/New.hs new file mode 100644 index 0000000..d18bf67 --- /dev/null +++ b/Web/View/StewardshipRoles/New.hs @@ -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| +
+

Grant Stewardship Role

+ {renderForm role hubs} +
+ |] + +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} +|] diff --git a/Web/View/StewardshipRoles/Show.hs b/Web/View/StewardshipRoles/Show.hs new file mode 100644 index 0000000..6d825e9 --- /dev/null +++ b/Web/View/StewardshipRoles/Show.hs @@ -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| +
+
+ Stewards + / +

{role.roleName}

+ {if isNothing role.revokedAt + then [hsx|active|] + else [hsx|revoked|]} +
+ +
+
+
+
Hub
+
{hub.name}
+
+
+
Assigned To
+
{role.assignedTo}
+
+
+
Granted At
+
{show role.grantedAt}
+
+
+
Revoked At
+
{maybe "–" show role.revokedAt}
+
+ {whenJust role.notes \n -> [hsx| +
+
Notes
+
{n}
+
+ |]} +
+
+ + {if isNothing role.revokedAt + then [hsx| +
+ + Revoke Role + +
+ |] + else mempty} +
+ |] diff --git a/Web/View/WidgetOwnerships/Edit.hs b/Web/View/WidgetOwnerships/Edit.hs new file mode 100644 index 0000000..2971d6e --- /dev/null +++ b/Web/View/WidgetOwnerships/Edit.hs @@ -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| +
+

Edit Ownership

+ {renderForm ownership hubs} +
+ |] + +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")] diff --git a/Web/View/WidgetOwnerships/Index.hs b/Web/View/WidgetOwnerships/Index.hs new file mode 100644 index 0000000..b37ddcf --- /dev/null +++ b/Web/View/WidgetOwnerships/Index.hs @@ -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| +
+

Widget Ownerships

+ + Assign Ownership + +
+ + {if null ownerships + then [hsx|

No ownership records yet.

|] + else [hsx| +
+ + + + + + + + + + + + + {forEach ownerships renderRow} + +
WidgetOwner HubSteward HubTypeEffective From
+
+ |]} + |] + 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| + + {widgetName o.widgetId} + {hubName o.ownerHubId} + + {maybe "–" hubName o.stewardHubId} + + + " text-xs px-2 py-0.5 rounded font-medium"}> + {o.ownershipType} + + + {show o.effectiveFrom} + + View + + + |] + +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" diff --git a/Web/View/WidgetOwnerships/New.hs b/Web/View/WidgetOwnerships/New.hs new file mode 100644 index 0000000..49ecb35 --- /dev/null +++ b/Web/View/WidgetOwnerships/New.hs @@ -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| +
+

Assign Ownership

+ {renderForm ownership widgets hubs} +
+ |] + +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")] diff --git a/Web/View/WidgetOwnerships/Show.hs b/Web/View/WidgetOwnerships/Show.hs new file mode 100644 index 0000000..4115b98 --- /dev/null +++ b/Web/View/WidgetOwnerships/Show.hs @@ -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| +
+
+ Ownerships + / +

Ownership Record

+
+ +
+
+ " text-sm px-3 py-1 rounded-full font-medium"}> + {ownership.ownershipType} + +
+ +
+
+
Widget
+
+ {widget.name} +
+
+
+
Owner Hub
+
{ownerHub.name}
+
+
+
Steward Hub
+
{maybe "Same as owner" (.name) mStewardHub}
+
+
+
Effective From
+
{show ownership.effectiveFrom}
+
+
+
Effective Until
+
{maybe "–" show ownership.effectiveUntil}
+
+
+
Created
+
{show ownership.createdAt}
+
+ {whenJust ownership.notes \n -> [hsx| +
+
Notes
+
{n}
+
+ |]} +
+
+ +
+ Edit +
+
+ |] diff --git a/docs/phase8-summary.md b/docs/phase8-summary.md new file mode 100644 index 0000000..03e6a66 --- /dev/null +++ b/docs/phase8-summary.md @@ -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 9–12 +roadmap (External API, Marketplace, AI Federation, Platform Memory). diff --git a/workplans/IHUB-WP-0008-ihf-phase8-federated-hub-maturity.md b/workplans/IHUB-WP-0008-ihf-phase8-federated-hub-maturity.md index 34138d3..b02d89f 100644 --- a/workplans/IHUB-WP-0008-ihf-phase8-federated-hub-maturity.md +++ b/workplans/IHUB-WP-0008-ihf-phase8-federated-hub-maturity.md @@ -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" ```