Files
inter-hub/Web/Controller/HubCapabilityManifests.hs
Bernd Worsch b5d73aa18b
Some checks failed
Test / test (push) Has been cancelled
feat(WP-0009): IHF GAAF Compliance Foundation — type registries, extension manifests, architectural contracts
Implements IHUB-WP-0009: closes four GAAF-2026 gaps before domain hub work begins.
- TypeRegistry helper + controllers/views (hub_kind, hub_capability_manifest)
- HubCapabilityManifest entity with validation and registry linkage
- ARCHITECTURE-LAYERS.md + CI-enforced boundary contracts
- Alembic migration 1743724800, fitness tests (Test/Architecture/)
- GAAF spec, Operational Architecture spec, domain hub extension guide
- Updates to CLAUDE.md, SCOPE.md, Schema.sql, Routes, FrontController, Types

state_hub_sync: pending (tunnel was STALE at completion time; run fix-consistency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 21:17:39 +00:00

170 lines
8.6 KiB
Haskell

module Web.Controller.HubCapabilityManifests where
import Web.Types
import Web.View.HubCapabilityManifests.Index
import Web.View.HubCapabilityManifests.Show
import Web.View.HubCapabilityManifests.New
import Web.View.HubCapabilityManifests.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (Value, Array, decode, encode)
import qualified Data.Vector as V
import Data.Maybe (mapMaybe)
instance Controller HubCapabilityManifestsController where
beforeAction = ensureIsUser
action HubCapabilityManifestsAction = autoRefresh do
manifests <- query @HubCapabilityManifest
|> orderByDesc #createdAt
|> fetch
hubs <- query @Hub |> fetch
render IndexView { manifests, hubs }
action ShowHubCapabilityManifestAction { hubCapabilityManifestId } = do
manifest <- fetch hubCapabilityManifestId
hub <- fetch manifest.hubId
render ShowView { manifest, hub }
action NewHubCapabilityManifestAction = do
mHubId <- paramOrNothing @(Id Hub) "hubId"
hubs <- query @Hub |> orderByAsc #name |> fetch
let manifest = newRecord @HubCapabilityManifest
case mHubId of
Just hubId -> do
-- Check if a manifest already exists for this hub
existing <- query @HubCapabilityManifest
|> filterWhere (#hubId, hubId)
|> fetchOneOrNothing
case existing of
Just m -> redirectTo EditHubCapabilityManifestAction { hubCapabilityManifestId = m.id }
Nothing -> render NewView { manifest = manifest |> set #hubId hubId, hubs }
Nothing -> render NewView { manifest, hubs }
action CreateHubCapabilityManifestAction = do
hubs <- query @Hub |> orderByAsc #name |> fetch
let manifest = newRecord @HubCapabilityManifest
manifest
|> fill @'["hubId", "manifestVersion", "capabilityDescription", "contact"]
|> set #status "draft"
|> validateField #hubId nonEmpty
|> ifValid \case
Left manifest -> render NewView { manifest, hubs }
Right manifest -> do
manifest <- createRecord manifest
setSuccessMessage "Capability manifest created (draft)"
redirectTo EditHubCapabilityManifestAction { hubCapabilityManifestId = manifest.id }
action EditHubCapabilityManifestAction { hubCapabilityManifestId } = do
manifest <- fetch hubCapabilityManifestId
hub <- fetch manifest.hubId
widgetTypeEntries <- sqlQuery "SELECT * FROM widget_type_registry WHERE status = 'active' ORDER BY label ASC" ()
eventTypeEntries <- sqlQuery "SELECT * FROM event_type_registry WHERE status = 'active' ORDER BY label ASC" ()
categoryEntries <- sqlQuery "SELECT * FROM annotation_category_registry WHERE status = 'active' ORDER BY label ASC" ()
policyScopeEntries <- sqlQuery "SELECT * FROM policy_scope_registry WHERE status = 'active' ORDER BY label ASC" ()
render EditView { manifest, hub, widgetTypeEntries, eventTypeEntries, categoryEntries, policyScopeEntries }
action UpdateHubCapabilityManifestAction { hubCapabilityManifestId } = do
manifest <- fetch hubCapabilityManifestId
hub <- fetch manifest.hubId
widgetTypeEntries <- sqlQuery "SELECT * FROM widget_type_registry WHERE status = 'active' ORDER BY label ASC" ()
eventTypeEntries <- sqlQuery "SELECT * FROM event_type_registry WHERE status = 'active' ORDER BY label ASC" ()
categoryEntries <- sqlQuery "SELECT * FROM annotation_category_registry WHERE status = 'active' ORDER BY label ASC" ()
policyScopeEntries <- sqlQuery "SELECT * FROM policy_scope_registry WHERE status = 'active' ORDER BY label ASC" ()
when (manifest.status == "active") do
setErrorMessage "Active manifests are read-only. Retire the current manifest and create a new draft to amend."
redirectTo ShowHubCapabilityManifestAction { hubCapabilityManifestId }
manifest
|> fill @'["manifestVersion", "capabilityDescription", "contact",
"declaredWidgetTypes", "declaredEventTypes",
"declaredAnnotationCategories", "declaredPolicyScopes"]
|> ifValid \case
Left manifest -> render EditView { manifest, hub, widgetTypeEntries, eventTypeEntries, categoryEntries, policyScopeEntries }
Right manifest -> do
updateRecord manifest
setSuccessMessage "Manifest updated"
redirectTo EditHubCapabilityManifestAction { hubCapabilityManifestId }
action ActivateManifestAction { hubCapabilityManifestId } = do
manifest <- fetch hubCapabilityManifestId
hub <- fetch manifest.hubId
-- Collect declared type names from JSONB arrays
let wTypes = jsonArrayTexts manifest.declaredWidgetTypes
eTypes = jsonArrayTexts manifest.declaredEventTypes
cats = jsonArrayTexts manifest.declaredAnnotationCategories
scopes = jsonArrayTexts manifest.declaredPolicyScopes
-- Conflict detection: check that each declared name is either
-- unregistered or already owned by this hub.
conflicts <- fmap concat $ mapM (checkConflict "widget_type_registry" hub.id) wTypes
eConflicts <- fmap concat $ mapM (checkConflict "event_type_registry" hub.id) eTypes
cConflicts <- fmap concat $ mapM (checkConflict "annotation_category_registry" hub.id) cats
pConflicts <- fmap concat $ mapM (checkConflict "policy_scope_registry" hub.id) scopes
let allConflicts = conflicts <> eConflicts <> cConflicts <> pConflicts
if not (null allConflicts)
then do
setErrorMessage ("Activation blocked — type name conflicts: " <> intercalate ", " allConflicts)
redirectTo ShowHubCapabilityManifestAction { hubCapabilityManifestId }
else do
-- Register declared types (idempotent — skip if already present)
mapM_ (upsertType "widget_type_registry" hub.id) wTypes
mapM_ (upsertType "event_type_registry" hub.id) eTypes
mapM_ (upsertType "annotation_category_registry" hub.id) cats
mapM_ (upsertType "policy_scope_registry" hub.id) scopes
now <- getCurrentTime
manifest |> set #status "active" |> set #activatedAt (Just now) |> updateRecord
setSuccessMessage "Manifest activated — all declared types are now registered"
redirectTo ShowHubCapabilityManifestAction { hubCapabilityManifestId }
action RetireManifestAction { hubCapabilityManifestId } = do
manifest <- fetch hubCapabilityManifestId
manifest |> set #status "retired" |> updateRecord
setSuccessMessage "Manifest retired. Types remain in registry but this manifest is no longer current."
redirectTo HubCapabilityManifestsAction
-- | Extract text values from a JSONB array (e.g. '["foo","bar"]').
jsonArrayTexts :: Value -> [Text]
jsonArrayTexts val = case val of
_ -> case decode (encode val) of
Just (arr :: Array) -> mapMaybe extractText (V.toList arr)
Nothing -> []
where
extractText (String t) = Just t
extractText _ = Nothing
-- | Check if 'name' in 'tableName' is owned by a different hub.
-- Returns [] if no conflict, or [error message] if conflict.
checkConflict ::
(?modelContext :: ModelContext) =>
Text -> Id Hub -> Text -> IO [Text]
checkConflict tableName hubId name = do
rows <- sqlQuery
("SELECT owner_hub_id FROM " <> tableName <> " WHERE name = ?")
(Only name)
case rows of
[] -> pure []
[Only Nothing] -> pure [] -- framework-level, no owner conflict
[Only (Just ownerId)] ->
if ownerId == hubId
then pure []
else pure ["Type '" <> name <> "' in " <> tableName <> " is already owned by another hub"]
_ -> pure []
-- | Insert a type name into the registry table if it doesn't exist.
upsertType ::
(?modelContext :: ModelContext) =>
Text -> Id Hub -> Text -> IO ()
upsertType tableName hubId name =
sqlExec
("INSERT INTO " <> tableName <> " (name, label, owner_hub_id, status) "
<> "VALUES (?, ?, ?, 'active') ON CONFLICT (name) DO NOTHING")
(name, name, hubId)
intercalate :: Text -> [Text] -> Text
intercalate _ [] = ""
intercalate _ [x] = x
intercalate sep (x:xs) = x <> sep <> intercalate sep xs