From 5d5e810886997667531f714f58efd56dfc927a0c Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 19 May 2026 02:16:39 +0200 Subject: [PATCH] feat: add vsm hub metadata --- .../Migration/1744588800-vsm-hub-metadata.sql | 21 ++++++++++++++ Application/Schema.sql | 19 +++++++++++- Test/Main.hs | 16 +++++++++- Web/Controller/Api/V2/HubRegistry.hs | 3 ++ Web/Controller/Api/V2/Hubs.hs | 29 +++++++++++++++++++ Web/Controller/Api/V2/OpenApi.hs | 19 ++++++++++++ Web/Controller/Api/V2/Sdk.hs | 10 +++++-- Web/View/HubRegistry/Index.hs | 12 ++++++++ Web/View/Hubs/Index.hs | 13 +++++++++ Web/View/Hubs/Show.hs | 12 ++++++++ .../IHUB-WP-0019-vsm-hub-bootstrap-api.md | 13 ++++++++- 11 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 Application/Migration/1744588800-vsm-hub-metadata.sql diff --git a/Application/Migration/1744588800-vsm-hub-metadata.sql b/Application/Migration/1744588800-vsm-hub-metadata.sql new file mode 100644 index 0000000..f880323 --- /dev/null +++ b/Application/Migration/1744588800-vsm-hub-metadata.sql @@ -0,0 +1,21 @@ +-- IHUB-WP-0019 T03 - first-class VSM hub metadata + +ALTER TABLE hubs + ADD COLUMN hub_family TEXT, + ADD COLUMN vsm_function TEXT, + ADD COLUMN vsm_system TEXT; + +ALTER TABLE hubs + ADD CONSTRAINT hubs_vsm_metadata_consistency CHECK ( + (hub_family IS NULL AND vsm_function IS NULL AND vsm_system IS NULL) + OR ( + hub_family = 'vsm' + AND vsm_function IS NOT NULL + AND vsm_function <> '' + AND vsm_system IN ('1', '2', '3', '3*', '4', '5', 'environment') + ) + ); + +CREATE INDEX hubs_hub_family_idx ON hubs (hub_family); +CREATE INDEX hubs_vsm_system_idx ON hubs (vsm_system) + WHERE vsm_system IS NOT NULL; diff --git a/Application/Schema.sql b/Application/Schema.sql index e5dafd0..92007db 100644 --- a/Application/Schema.sql +++ b/Application/Schema.sql @@ -25,7 +25,19 @@ CREATE TABLE hubs ( domain TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, api_key TEXT, - hub_kind TEXT NOT NULL DEFAULT 'domain' + hub_kind TEXT NOT NULL DEFAULT 'domain', + hub_family TEXT, + vsm_function TEXT, + vsm_system TEXT, + CONSTRAINT hubs_vsm_metadata_consistency CHECK ( + (hub_family IS NULL AND vsm_function IS NULL AND vsm_system IS NULL) + OR ( + hub_family = 'vsm' + AND vsm_function IS NOT NULL + AND vsm_function <> '' + AND vsm_system IN ('1', '2', '3', '3*', '4', '5', 'environment') + ) + ) ); -- Widgets — smallest semantically governable interaction units @@ -557,6 +569,11 @@ CREATE INDEX hubs_hub_kind_idx ON hubs (hub_kind); CREATE UNIQUE INDEX hubs_one_framework_idx ON hubs (hub_kind) WHERE hub_kind = 'framework'; +-- IHUB-WP-0019 T03 — first-class VSM hub metadata +CREATE INDEX hubs_hub_family_idx ON hubs (hub_family); +CREATE INDEX hubs_vsm_system_idx ON hubs (vsm_system) + WHERE vsm_system IS NOT NULL; + -- T03 — Type registries CREATE TABLE widget_type_registry ( diff --git a/Test/Main.hs b/Test/Main.hs index eb96eb9..b33ddfe 100644 --- a/Test/Main.hs +++ b/Test/Main.hs @@ -8,7 +8,9 @@ import Web.Controller.Api.V2.InteractionEvents ( declaredEventTypeNames, manifestAllowsEvent, metadataFromJsonBody , metadataParamOrEmpty ) -import Web.Controller.Api.V2.Hubs (missingRequiredFields, validCreateHubKind) +import Web.Controller.Api.V2.Hubs + ( missingRequiredFields, validCreateHubKind, validVsmMetadata + , validVsmSystem ) import Web.Controller.Api.V2.HubCapabilityManifests ( jsonArrayTexts, textArrayFieldFromJsonBody ) import Web.Controller.Api.V2.ApiConsumers (positiveLimit) @@ -63,6 +65,18 @@ main = hspec do ] `shouldBe` ["slug", "name"] + it "accepts complete VSM hub classification for ops-hub" do + validVsmMetadata (Just "vsm") (Just "operations") (Just "1") + `shouldBe` True + validVsmSystem "1" `shouldBe` True + validVsmSystem "6" `shouldBe` False + + it "rejects partial VSM metadata" do + validVsmMetadata (Just "vsm") (Just "operations") Nothing + `shouldBe` False + validVsmMetadata Nothing (Just "operations") (Just "1") + `shouldBe` False + it "accepts widget statuses supported by the UI create flow" do validWidgetStatus "active" `shouldBe` True validWidgetStatus "deprecated" `shouldBe` True diff --git a/Web/Controller/Api/V2/HubRegistry.hs b/Web/Controller/Api/V2/HubRegistry.hs index d7631c5..abc9f50 100644 --- a/Web/Controller/Api/V2/HubRegistry.hs +++ b/Web/Controller/Api/V2/HubRegistry.hs @@ -61,6 +61,9 @@ hubDetailJson hub mManifest mSnapshot = , "slug" .= hub.slug , "domain" .= hub.domain , "hubKind" .= hub.hubKind + , "hubFamily" .= hub.hubFamily + , "vsmFunction" .= hub.vsmFunction + , "vsmSystem" .= hub.vsmSystem , "gaafStatus" .= gaafIndicator , "manifest" .= fmap manifestSummary mManifest , "healthScore" .= fmap (.healthScore) mSnapshot diff --git a/Web/Controller/Api/V2/Hubs.hs b/Web/Controller/Api/V2/Hubs.hs index 34a1f2d..7e62898 100644 --- a/Web/Controller/Api/V2/Hubs.hs +++ b/Web/Controller/Api/V2/Hubs.hs @@ -45,6 +45,9 @@ createHub = do name = paramOrNothing @Text "name" domain = paramOrNothing @Text "domain" kind = fromMaybe "domain" (nonEmptyText =<< paramOrNothing @Text "hubKind") + hubFamily = nonEmptyText =<< paramOrNothing @Text "hubFamily" + vsmFunction = nonEmptyText =<< paramOrNothing @Text "vsmFunction" + vsmSystem = nonEmptyText =<< paramOrNothing @Text "vsmSystem" let missing = missingRequiredFields [ ("slug", slug) @@ -65,6 +68,14 @@ createHub = do , "valid" .= validCreateHubKinds ] + unless (validVsmMetadata hubFamily vsmFunction vsmSystem) do + respondWithStatus 422 $ object + [ "error" .= ("Invalid VSM hub metadata" :: Text) + , "code" .= ("invalid_vsm_metadata" :: Text) + , "hint" .= ("Use no VSM fields, or set hubFamily=vsm with vsmFunction and vsmSystem." :: Text) + , "validVsmSystems" .= validVsmSystems + ] + let Just slugText = slug Just nameText = name Just domainText = domain @@ -84,6 +95,9 @@ createHub = do |> set #name nameText |> set #domain domainText |> set #hubKind kind + |> set #hubFamily hubFamily + |> set #vsmFunction vsmFunction + |> set #vsmSystem vsmSystem |> createRecord respondWithStatus 201 (hubToJson hub) @@ -94,6 +108,9 @@ hubToJson hub = object , "name" .= hub.name , "domain" .= hub.domain , "hubKind" .= hub.hubKind + , "hubFamily" .= hub.hubFamily + , "vsmFunction" .= hub.vsmFunction + , "vsmSystem" .= hub.vsmSystem , "createdAt" .= hub.createdAt ] @@ -103,6 +120,18 @@ validCreateHubKinds = ["domain", "shared"] validCreateHubKind :: Text -> Bool validCreateHubKind kind = kind `elem` validCreateHubKinds +validVsmSystems :: [Text] +validVsmSystems = ["1", "2", "3", "3*", "4", "5", "environment"] + +validVsmSystem :: Text -> Bool +validVsmSystem systemName = systemName `elem` validVsmSystems + +validVsmMetadata :: Maybe Text -> Maybe Text -> Maybe Text -> Bool +validVsmMetadata Nothing Nothing Nothing = True +validVsmMetadata (Just "vsm") (Just functionName) (Just systemName) = + functionName /= "" && validVsmSystem systemName +validVsmMetadata _ _ _ = False + missingRequiredFields :: [(Text, Maybe Text)] -> [Text] missingRequiredFields fields = [ name | (name, value) <- fields, maybe True (== "") value ] diff --git a/Web/Controller/Api/V2/OpenApi.hs b/Web/Controller/Api/V2/OpenApi.hs index 1b088b2..99aa6fa 100644 --- a/Web/Controller/Api/V2/OpenApi.hs +++ b/Web/Controller/Api/V2/OpenApi.hs @@ -92,6 +92,7 @@ buildOpenApiSpec = do ] ] , "Hub" .= hubSchema + , "CreateHubRequest" .= createHubRequestSchema , "HubCapabilityManifest" .= manifestSchema , "ApiConsumer" .= apiConsumerSchema , "ApiKey" .= apiKeySchema @@ -320,10 +321,28 @@ hubSchema = object , "name" .= strProp , "domain" .= strProp , "hubKind" .= object ["type" .= ("string" :: Text), "enum" .= ["domain" :: Text, "shared"]] + , "hubFamily" .= object ["type" .= ("string" :: Text), "enum" .= ["vsm" :: Text]] + , "vsmFunction" .= strProp + , "vsmSystem" .= object ["type" .= ("string" :: Text), "enum" .= ["1" :: Text, "2", "3", "3*", "4", "5", "environment"]] , "createdAt" .= object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)] ] ] +createHubRequestSchema :: Value +createHubRequestSchema = object + [ "type" .= ("object" :: Text) + , "required" .= (["slug", "name", "domain"] :: [Text]) + , "properties" .= object + [ "slug" .= strProp + , "name" .= strProp + , "domain" .= strProp + , "hubKind" .= object ["type" .= ("string" :: Text), "enum" .= ["domain" :: Text, "shared"]] + , "hubFamily" .= object ["type" .= ("string" :: Text), "enum" .= ["vsm" :: Text]] + , "vsmFunction" .= strProp + , "vsmSystem" .= object ["type" .= ("string" :: Text), "enum" .= ["1" :: Text, "2", "3", "3*", "4", "5", "environment"]] + ] + ] + widgetSchema :: Value widgetSchema = object [ "type" .= ("object" :: Text) diff --git a/Web/Controller/Api/V2/Sdk.hs b/Web/Controller/Api/V2/Sdk.hs index d437981..2072102 100644 --- a/Web/Controller/Api/V2/Sdk.hs +++ b/Web/Controller/Api/V2/Sdk.hs @@ -94,7 +94,7 @@ tsSdkClientClass = T.unlines , " });" , " }" , "" - , " async createHub(body: { slug: string; name: string; domain: string; hubKind?: 'domain' | 'shared' }) {" + , " async createHub(body: { slug: string; name: string; domain: string; hubKind?: 'domain' | 'shared'; hubFamily?: 'vsm'; vsmFunction?: string; vsmSystem?: '1' | '2' | '3' | '3*' | '4' | '5' | 'environment' }) {" , " return this.fetch('/hubs', 'POST', body).then(r => r.json());" , " }" , "" @@ -177,8 +177,12 @@ pyClientClass = T.unlines , " with urllib.request.urlopen(req) as resp:" , " return json.loads(resp.read())" , "" - , " def create_hub(self, slug: str, name: str, domain: str, hub_kind: str = 'domain') -> dict:" - , " return self._request('/hubs', 'POST', {'slug': slug, 'name': name, 'domain': domain, 'hubKind': hub_kind})" + , " def create_hub(self, slug: str, name: str, domain: str, hub_kind: str = 'domain', hub_family: Optional[str] = None, vsm_function: Optional[str] = None, vsm_system: Optional[str] = None) -> dict:" + , " body: dict = {'slug': slug, 'name': name, 'domain': domain, 'hubKind': hub_kind}" + , " if hub_family: body['hubFamily'] = hub_family" + , " if vsm_function: body['vsmFunction'] = vsm_function" + , " if vsm_system: body['vsmSystem'] = vsm_system" + , " return self._request('/hubs', 'POST', body)" , "" , " def create_hub_capability_manifest(self, body: dict) -> dict:" , " return self._request('/hub-capability-manifests', 'POST', body)" diff --git a/Web/View/HubRegistry/Index.hs b/Web/View/HubRegistry/Index.hs index 81948b2..016ac93 100644 --- a/Web/View/HubRegistry/Index.hs +++ b/Web/View/HubRegistry/Index.hs @@ -53,6 +53,7 @@ renderRow row@HubRegistryRow { hub, mManifest, mLatestSnapshot } = {hub.name} {hub.hubKind} + {classificationBadge hub} {gaafBadge gs}
@@ -74,6 +75,17 @@ gaafBadge GaafDraftOnly = gaafBadge GaafNoManifest = [hsx|no manifest|] +classificationBadge :: Hub -> Html +classificationBadge hub = + case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of + (Just "vsm", Just functionName, Just systemName) -> + [hsx|VSM {functionName} / {vsmSystemLabel systemName}|] + _ -> mempty + +vsmSystemLabel :: Text -> Text +vsmSystemLabel "environment" = "Environment" +vsmSystemLabel systemName = "System " <> systemName + healthScoreBadge :: Int -> Html healthScoreBadge s = let cls :: Text diff --git a/Web/View/Hubs/Index.hs b/Web/View/Hubs/Index.hs index c03d815..c371c03 100644 --- a/Web/View/Hubs/Index.hs +++ b/Web/View/Hubs/Index.hs @@ -26,6 +26,7 @@ instance View IndexView where Slug Domain Kind + Family @@ -41,6 +42,17 @@ kindBadge "framework" = [hsx|shared|] kindBadge _ = [hsx|domain|] +classificationBadge :: Hub -> Html +classificationBadge hub = + case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of + (Just "vsm", Just functionName, Just systemName) -> + [hsx|VSM {functionName} / {vsmSystemLabel systemName}|] + _ -> [hsx|-|] + +vsmSystemLabel :: Text -> Text +vsmSystemLabel "environment" = "Environment" +vsmSystemLabel systemName = "System " <> systemName + renderHub :: Hub -> Html renderHub hub = [hsx| @@ -53,6 +65,7 @@ renderHub hub = [hsx| {hub.slug} {hub.domain} {kindBadge hub.hubKind} + {classificationBadge hub} Edit diff --git a/Web/View/Hubs/Show.hs b/Web/View/Hubs/Show.hs index de7fd0c..07e11d5 100644 --- a/Web/View/Hubs/Show.hs +++ b/Web/View/Hubs/Show.hs @@ -27,6 +27,7 @@ instance View ShowView where

{hub.name}

{kindBadge hub.hubKind} + {classificationBadge hub}

{hub.slug} @@ -223,6 +224,17 @@ kindBadge "framework" = [hsx|shared|] kindBadge _ = [hsx|domain|] +classificationBadge :: Hub -> Html +classificationBadge hub = + case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of + (Just "vsm", Just functionName, Just systemName) -> + [hsx|VSM {functionName} / {vsmSystemLabel systemName}|] + _ -> mempty + +vsmSystemLabel :: Text -> Text +vsmSystemLabel "environment" = "Environment" +vsmSystemLabel systemName = "System " <> systemName + maybeText :: Maybe Text -> [Text] maybeText Nothing = [] maybeText (Just t) = [t] diff --git a/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md b/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md index 41dc542..de234b1 100644 --- a/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md +++ b/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md @@ -146,7 +146,7 @@ shell does not have `IHP_LIB`/the IHP dev environment loaded. ```task id: IHUB-WP-0019-T03 -status: todo +status: done priority: medium state_hub_task_id: "a90a0220-3d02-4b97-9fbf-a6bbbfa5019c" ``` @@ -168,6 +168,17 @@ Candidate placement: Done when: `ops-hub` can be represented as the VSM Operations / System 1 hub without hiding that classification inside prose. +Implementation note (2026-05-19): chose new nullable columns on `hubs` +(`hub_family`, `vsm_function`, `vsm_system`) because the VSM role is hub +identity/classification metadata, not manifest vocabulary. Added migration +`1744588800-vsm-hub-metadata.sql`, schema constraints, v2 hub create/list/show +JSON, hub registry JSON, compact registry/UI badges, OpenAPI request/response +fields, SDK parameters, and validation tests. API validation now accepts either +no VSM fields or `hubFamily=vsm` with non-empty `vsmFunction` and a supported +`vsmSystem` (`1`, `2`, `3`, `3*`, `4`, `5`, or `environment`). `git diff +--check` passed; `scripts/compile-check` is still blocked in this shell because +`IHP_LIB` is not set. + --- ### T04 — Add API consumer and API key bootstrap support