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
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| +No archived artifacts yet.
|] + else [hsx| +| Subject Type | +Subject ID | +Reason | +Archived By | +Archived At | ++ |
|---|
Full traceability chain for this widget.
+ +No events recorded.
|] + else [hsx| +| Event Type | +Occurred At | +
|---|
No active routing rules.
|] + else [hsx| +No active policy overlays.
|] + else [hsx| +All hubs have active stewards.
|] + else [hsx| +Hubs without stewards:
+No artifacts archived in the last 90 days.
|] + else [hsx| ++ Only draft overlays can be edited. Once activated, this policy becomes immutable. +
+ {renderForm overlay} +No policy overlays yet.
|] + else [hsx| +| Title | +Status | +Enforced From | +Created | ++ |
|---|
No active policy overlays.
++ Enforced from: {maybe "–" show o.enforcedFrom} +
+No routing rules configured yet.
|] + else [hsx| +| Source → Target | +Match Category | +Match Widget Type | +Priority | +Status | ++ |
|---|
+ Requirement candidates routed to this hub from other hubs. +
+ + {if null candidates + then [hsx|No candidates routed to this hub yet.
|] + else [hsx| +| Summary | +Category | +Status | +Created | ++ |
|---|
No stewardship roles assigned yet.
|] + else [hsx| +| Role | +Assigned To | +Granted | +Status | ++ |
|---|
No ownership records yet.
|] + else [hsx| +| Widget | +Owner Hub | +Steward Hub | +Type | +Effective From | ++ |
|---|