diff --git a/Application/Helper/Controller.hs b/Application/Helper/Controller.hs index 3aa7c2a..dd6cfca 100644 --- a/Application/Helper/Controller.hs +++ b/Application/Helper/Controller.hs @@ -1,5 +1,71 @@ module Application.Helper.Controller where import IHP.ControllerPrelude +import Generated.Types +import Data.Time.Clock (addUTCTime) +import Data.List (sortBy) --- Here you can add functions which are available in all your controllers \ No newline at end of file +-- Here you can add functions which are available in all your controllers + +-- | Returns the set of widget IDs that are currently in regression. +-- +-- A regression is defined as: a widget that has an OutcomeSignal(improved) +-- for any deployment, followed by a new Annotation(severity IN high/critical) +-- created more than 1 day after the signal's observed_at (grace period). +regressedWidgetIds :: [OutcomeSignal] -> [Annotation] -> [Id Widget] +regressedWidgetIds signals annotations = + [ wid + | wid <- nub (map (.widgetId) signals) + , isInRegression signals annotations wid + ] + +isInRegression :: [OutcomeSignal] -> [Annotation] -> Id Widget -> Bool +isInRegression signals annotations wid = + let improvedSignals = filter (\s -> s.widgetId == wid && s.signalType == "improved") signals + highAnns = filter (\a -> a.widgetId == wid + && a.severity `elem` ["high", "critical"] + && isNothing a.retractedAt) annotations + graceEnd sig = addUTCTime (24 * 3600) sig.observedAt + in any (\sig -> any (\ann -> ann.createdAt > graceEnd sig) highAnns) improvedSignals + +-- | Computes the number of completed improvement cycles per widget. +-- +-- A cycle is counted when: +-- 1. A RequirementCandidate for the widget was accepted +-- 2. A DecisionRecord exists for that requirement/candidate +-- 3. A DeploymentRecord exists for that decision +-- 4. A new RequirementCandidate was subsequently created for the same widget +-- +-- Returns a list of (widgetId, cycleCount) for widgets with cycleCount >= 1, +-- sorted descending by cycleCount. +widgetCycleCounts + :: [RequirementCandidate] + -> [Requirement] + -> [DecisionRecord] + -> [DeploymentRecord] + -> [(Id Widget, Int)] +widgetCycleCounts candidates requirements decisions deployments = + sortBy (\(_, a) (_, b) -> compare b a) + [ (wid, cycleCount wid) + | wid <- nub (map (.sourceWidgetId) candidates) + , cycleCount wid >= 1 + ] + where + -- A completed cycle: accepted candidate → requirement → decision → deployment + completedCycleDeploymentTimes wid = + [ dr.deployedAt + | c <- filter (\x -> x.sourceWidgetId == wid && x.status == "accepted") candidates + , req <- filter (\x -> x.sourceCandidateId == c.id) requirements + , dec <- filter (\x -> x.requirementId == Just req.id) decisions + , dr <- filter (\x -> x.decisionId == dec.id) deployments + ] + + cycleCount wid = + let deplTimes = completedCycleDeploymentTimes wid + -- For each completed cycle, check if a subsequent candidate was created + widCandidates = filter (\x -> x.sourceWidgetId == wid) candidates + in length + [ () + | deplTime <- deplTimes + , any (\c -> c.createdAt > deplTime) widCandidates + ] \ No newline at end of file diff --git a/Application/Migration/1743292800-ihf-phase4-outcome-observation.sql b/Application/Migration/1743292800-ihf-phase4-outcome-observation.sql new file mode 100644 index 0000000..c87f787 --- /dev/null +++ b/Application/Migration/1743292800-ihf-phase4-outcome-observation.sql @@ -0,0 +1,57 @@ +-- IHF Phase 4: Outcome Observation and Antifragility Loop +-- Adds: deployment_records, outcome_signals (append-only), change_evaluations + +CREATE TABLE deployment_records ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + impl_ref_id UUID REFERENCES implementation_change_references(id) ON DELETE SET NULL, + decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE RESTRICT, + version_ref TEXT NOT NULL, + deployed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + deployed_by UUID REFERENCES users(id), + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX deployment_records_decision_id_idx ON deployment_records (decision_id); +CREATE INDEX deployment_records_deployed_at_idx ON deployment_records (deployed_at DESC); + +CREATE TABLE outcome_signals ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + deployment_id UUID NOT NULL REFERENCES deployment_records(id) ON DELETE CASCADE, + signal_type TEXT NOT NULL, + value NUMERIC, + observed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX outcome_signals_widget_id_idx ON outcome_signals (widget_id); +CREATE INDEX outcome_signals_deployment_id_idx ON outcome_signals (deployment_id); +CREATE INDEX outcome_signals_observed_at_idx ON outcome_signals (observed_at DESC); + +CREATE OR REPLACE FUNCTION prevent_outcome_signal_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'outcome_signals is append-only: UPDATE and DELETE are not permitted'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER outcome_signals_no_update + BEFORE UPDATE ON outcome_signals + FOR EACH ROW EXECUTE FUNCTION prevent_outcome_signal_mutation(); + +CREATE TRIGGER outcome_signals_no_delete + BEFORE DELETE ON outcome_signals + FOR EACH ROW EXECUTE FUNCTION prevent_outcome_signal_mutation(); + +CREATE TABLE change_evaluations ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + deployment_id UUID NOT NULL REFERENCES deployment_records(id) ON DELETE CASCADE, + decision_id UUID REFERENCES decision_records(id) ON DELETE SET NULL, + score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5), + rationale TEXT NOT NULL, + evaluated_by UUID REFERENCES users(id), + evaluated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + UNIQUE (deployment_id) +); + +CREATE INDEX change_evaluations_deployment_id_idx ON change_evaluations (deployment_id); diff --git a/Application/Schema.sql b/Application/Schema.sql index 6ee82ad..eb5f286 100644 --- a/Application/Schema.sql +++ b/Application/Schema.sql @@ -205,3 +205,61 @@ CREATE INDEX impl_change_refs_decision_id_idx ON implementation_change_reference -- Back-reference: which candidate was promoted to a requirement (Phase 3) ALTER TABLE requirement_candidates ADD COLUMN requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL; + +-- Deployment records — connect decisions to deployed versions (Phase 4) +CREATE TABLE deployment_records ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + impl_ref_id UUID REFERENCES implementation_change_references(id) ON DELETE SET NULL, + decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE RESTRICT, + version_ref TEXT NOT NULL, + deployed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + deployed_by UUID REFERENCES users(id), + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX deployment_records_decision_id_idx ON deployment_records (decision_id); +CREATE INDEX deployment_records_deployed_at_idx ON deployment_records (deployed_at DESC); + +-- Outcome signals — append-only observation of widget behaviour post-deployment (Phase 4) +CREATE TABLE outcome_signals ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + deployment_id UUID NOT NULL REFERENCES deployment_records(id) ON DELETE CASCADE, + signal_type TEXT NOT NULL, + value NUMERIC, + observed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX outcome_signals_widget_id_idx ON outcome_signals (widget_id); +CREATE INDEX outcome_signals_deployment_id_idx ON outcome_signals (deployment_id); +CREATE INDEX outcome_signals_observed_at_idx ON outcome_signals (observed_at DESC); + +CREATE OR REPLACE FUNCTION prevent_outcome_signal_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'outcome_signals is append-only: UPDATE and DELETE are not permitted'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER outcome_signals_no_update + BEFORE UPDATE ON outcome_signals + FOR EACH ROW EXECUTE FUNCTION prevent_outcome_signal_mutation(); + +CREATE TRIGGER outcome_signals_no_delete + BEFORE DELETE ON outcome_signals + FOR EACH ROW EXECUTE FUNCTION prevent_outcome_signal_mutation(); + +-- Change evaluations — one score per deployment (Phase 4) +CREATE TABLE change_evaluations ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + deployment_id UUID NOT NULL REFERENCES deployment_records(id) ON DELETE CASCADE, + decision_id UUID REFERENCES decision_records(id) ON DELETE SET NULL, + score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5), + rationale TEXT NOT NULL, + evaluated_by UUID REFERENCES users(id), + evaluated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + UNIQUE (deployment_id) +); + +CREATE INDEX change_evaluations_deployment_id_idx ON change_evaluations (deployment_id); diff --git a/SCOPE.md b/SCOPE.md index de73719..d4b6d9a 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 3 complete — governance and decision linkage 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 audit trail dashboard) -- Stability: core artifact model and schema are stable; Phase 3 data model (Requirement, DecisionRecord, PolicyReference, ImplementationChangeReference) is additive and stable +- Status: Phase 4 complete — outcome observation and antifragility loop 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) +- Stability: core artifact model and schema are stable; Phase 4 data model (DeploymentRecord, OutcomeSignal, ChangeEvaluation) is additive and stable; outcome signals and interaction events are append-only (DB triggers) - Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start --- diff --git a/Test/Integration.hs b/Test/Integration.hs index 5d6e3c5..b21b81c 100644 --- a/Test/Integration.hs +++ b/Test/Integration.hs @@ -7,6 +7,10 @@ import IHP.Log.Types import IHP.ControllerPrelude hiding (query) import System.Environment (lookupEnv) import Generated.Types +import Data.Time.Clock (addUTCTime, getCurrentTime) +import Application.Helper.Controller (regressedWidgetIds, widgetCycleCounts) +import Control.Exception (try, SomeException) +import Data.Either (isLeft) -- Integration tests run with a temporary PostgreSQL database. -- Run: nix flake check (or `test` inside devenv) @@ -657,3 +661,284 @@ main = do let accepted = filter (\d -> d.outcome == "accepted") decisions length accepted `shouldBe` 1 deleteRecord hub + + -- ---------------------------------------------------------------- + -- Phase 4: DeploymentRecord create + link to decision + -- ---------------------------------------------------------------- + describe "DeploymentRecord" do + it "creates a deployment linked to a decision" do + hub <- newRecord @Hub + |> set #slug "p4-dr-hub" |> set #name "P4 DR" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "DR Widget" |> set #widgetType "form" + |> createRecord + candidate <- newRecord @RequirementCandidate + |> set #title "DR candidate" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" |> createRecord + dr <- newRecord @DecisionRecord + |> set #title "DR decision" |> set #rationale "r" + |> set #outcome "accepted" |> set #candidateId (Just candidate.id) + |> createRecord + deployment <- newRecord @DeploymentRecord + |> set #decisionId dr.id + |> set #versionRef "v1.0.0" + |> createRecord + deployment.versionRef `shouldBe` "v1.0.0" + deployment.decisionId `shouldBe` dr.id + fetched <- fetch deployment.id + fetched.versionRef `shouldBe` "v1.0.0" + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Phase 4: OutcomeSignal append-only (DB trigger) + -- ---------------------------------------------------------------- + describe "OutcomeSignal append-only" do + it "inserts signal successfully" do + hub <- newRecord @Hub + |> set #slug "p4-sig-hub" |> set #name "P4 Sig" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Sig Widget" |> set #widgetType "form" + |> createRecord + candidate <- newRecord @RequirementCandidate + |> set #title "Sig candidate" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" |> createRecord + decision <- newRecord @DecisionRecord + |> set #title "Sig decision" |> set #rationale "r" + |> set #outcome "accepted" |> set #candidateId (Just candidate.id) + |> createRecord + deployment <- newRecord @DeploymentRecord + |> set #decisionId decision.id |> set #versionRef "v1.0.0" + |> createRecord + sig <- newRecord @OutcomeSignal + |> set #deploymentId deployment.id + |> set #widgetId widget.id + |> set #signalType "improved" + |> createRecord + sig.signalType `shouldBe` "improved" + -- DB trigger prevents UPDATE — exception is caught, connection continues + result <- try (updateRecord (sig |> set #signalType "regressed") :: IO OutcomeSignal) + :: IO (Either SomeException OutcomeSignal) + isLeft result `shouldBe` True + deleteRecord hub + + it "DB trigger prevents DELETE on outcome_signals" do + hub <- newRecord @Hub + |> set #slug "p4-sigdel-hub" |> set #name "P4 SigDel" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "SigDel Widget" |> set #widgetType "form" + |> createRecord + candidate <- newRecord @RequirementCandidate + |> set #title "SigDel cand" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" |> createRecord + decision <- newRecord @DecisionRecord + |> set #title "SigDel dec" |> set #rationale "r" + |> set #outcome "accepted" |> set #candidateId (Just candidate.id) + |> createRecord + deployment <- newRecord @DeploymentRecord + |> set #decisionId decision.id |> set #versionRef "v1.0.0" + |> createRecord + sig <- newRecord @OutcomeSignal + |> set #deploymentId deployment.id + |> set #widgetId widget.id + |> set #signalType "neutral" + |> createRecord + result <- try (deleteRecord sig) :: IO (Either SomeException ()) + isLeft result `shouldBe` True + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Phase 4: Regression detection + -- ---------------------------------------------------------------- + describe "Regression detection" do + it "detects widget in regression (improved signal then high annotation)" do + hub <- newRecord @Hub + |> set #slug "p4-reg-hub" |> set #name "P4 Reg" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Reg Widget" |> set #widgetType "form" + |> createRecord + candidate <- newRecord @RequirementCandidate + |> set #title "Reg cand" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" |> createRecord + decision <- newRecord @DecisionRecord + |> set #title "Reg dec" |> set #rationale "r" + |> set #outcome "accepted" |> set #candidateId (Just candidate.id) + |> createRecord + deployment <- newRecord @DeploymentRecord + |> set #decisionId decision.id |> set #versionRef "v1.0.0" + |> createRecord + -- Record an improved signal with observedAt 10 days ago + now <- getCurrentTime + let tenDaysAgo = addUTCTime (negate $ 10 * 24 * 3600) now + sig <- newRecord @OutcomeSignal + |> set #deploymentId deployment.id + |> set #widgetId widget.id + |> set #signalType "improved" + |> set #observedAt tenDaysAgo + |> createRecord + -- Annotation created now — which is 10 days after signal (> 1 day grace) + ann <- newRecord @Annotation + |> set #widgetId widget.id + |> set #body "High severity friction recurred" + |> set #category "friction" + |> set #severity "high" + |> set #actorType "user" + |> createRecord + -- Run regression detection + allSignals <- query @OutcomeSignal |> filterWhere (#widgetId, widget.id) |> fetch + allAnns <- query @Annotation |> filterWhere (#widgetId, widget.id) |> fetch + let regressed = regressedWidgetIds allSignals allAnns + regressed `shouldContain` [widget.id] + deleteRecord hub + + it "does not flag widget without improved signal" do + hub <- newRecord @Hub + |> set #slug "p4-noreg-hub" |> set #name "P4 NoReg" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "NoReg Widget" |> set #widgetType "form" + |> createRecord + ann <- newRecord @Annotation + |> set #widgetId widget.id |> set #body "high ann" + |> set #category "friction" |> set #severity "high" + |> set #actorType "user" |> createRecord + let regressed = regressedWidgetIds [] [ann] + regressed `shouldBe` [] + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Phase 4: ChangeEvaluation create + idempotent + -- ---------------------------------------------------------------- + describe "ChangeEvaluation" do + it "creates one evaluation per deployment and rejects duplicates" do + hub <- newRecord @Hub + |> set #slug "p4-eval-hub" |> set #name "P4 Eval" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Eval Widget" |> set #widgetType "form" + |> createRecord + candidate <- newRecord @RequirementCandidate + |> set #title "Eval cand" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" |> createRecord + decision <- newRecord @DecisionRecord + |> set #title "Eval dec" |> set #rationale "r" + |> set #outcome "accepted" |> set #candidateId (Just candidate.id) + |> createRecord + deployment <- newRecord @DeploymentRecord + |> set #decisionId decision.id |> set #versionRef "v1.0.0" + |> createRecord + eval1 <- newRecord @ChangeEvaluation + |> set #deploymentId deployment.id + |> set #score 4 + |> set #rationale "Good improvement" + |> createRecord + eval1.score `shouldBe` 4 + -- Second evaluation for same deployment → UNIQUE constraint violation + result <- try (newRecord @ChangeEvaluation + |> set #deploymentId deployment.id + |> set #score 3 + |> set #rationale "Duplicate" + |> createRecord :: IO ChangeEvaluation) + :: IO (Either SomeException ChangeEvaluation) + isLeft result `shouldBe` True + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Phase 4: Recurrence tracking + -- ---------------------------------------------------------------- + describe "Recurrence tracking" do + it "counts cycles for a widget with 2 completed cycles" do + hub <- newRecord @Hub + |> set #slug "p4-rec-hub" |> set #name "P4 Rec" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Rec Widget" |> set #widgetType "form" + |> createRecord + -- Cycle 1: candidate → req → decision → deployment + c1 <- newRecord @RequirementCandidate + |> set #title "Rec cand 1" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" |> createRecord + req1 <- newRecord @Requirement + |> set #title c1.title |> set #description "d" + |> set #sourceCandidateId c1.id |> set #status "active" + |> createRecord + dec1 <- newRecord @DecisionRecord + |> set #title "Rec dec 1" |> set #rationale "r" + |> set #outcome "accepted" |> set #requirementId (Just req1.id) + |> createRecord + now <- getCurrentTime + let pastTime = addUTCTime (negate $ 2 * 24 * 3600) now + futureTime = addUTCTime (24 * 3600) now + dep1 <- newRecord @DeploymentRecord + |> set #decisionId dec1.id |> set #versionRef "v1.0" + |> set #deployedAt pastTime + |> createRecord + -- Cycle 2: new candidate after dep1 deployment (created in the future relative to dep1) + c2 <- newRecord @RequirementCandidate + |> set #title "Rec cand 2" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" + |> set #createdAt futureTime + |> createRecord + -- Compute cycle count + allCandidates <- query @RequirementCandidate |> filterWhere (#sourceWidgetId, widget.id) |> fetch + allRequirements <- query @Requirement |> fetch + allDecisions <- query @DecisionRecord |> fetch + allDeployments <- query @DeploymentRecord |> fetch + let cycles = widgetCycleCounts allCandidates allRequirements allDecisions allDeployments + let mCount = lookup widget.id cycles + mCount `shouldBe` Just 1 + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Phase 4: Antifragility dashboard data fetch + -- ---------------------------------------------------------------- + describe "Antifragility dashboard data fetch" do + it "returns correct deployment and signal counts" do + hub <- newRecord @Hub + |> set #slug "p4-afd-hub" |> set #name "P4 AFD" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "AFD Widget" |> set #widgetType "panel" + |> createRecord + candidate <- newRecord @RequirementCandidate + |> set #title "AFD cand" |> set #description "d" + |> set #sourceWidgetId widget.id |> set #category "friction" + |> set #status "accepted" |> createRecord + req <- newRecord @Requirement + |> set #title candidate.title |> set #description "d" + |> set #sourceCandidateId candidate.id |> set #status "active" + |> createRecord + decision <- newRecord @DecisionRecord + |> set #title "AFD decision" |> set #rationale "r" + |> set #outcome "accepted" |> set #requirementId (Just req.id) + |> createRecord + dep <- newRecord @DeploymentRecord + |> set #decisionId decision.id |> set #versionRef "v1.0.0" + |> createRecord + _s1 <- newRecord @OutcomeSignal + |> set #deploymentId dep.id |> set #widgetId widget.id + |> set #signalType "improved" |> createRecord + _s2 <- newRecord @OutcomeSignal + |> set #deploymentId dep.id |> set #widgetId widget.id + |> set #signalType "neutral" |> createRecord + -- Verify query path used by antifragility dashboard + let widgetIds = [widget.id] + deployments <- query @DeploymentRecord + |> filterWhereIn (#decisionId, [decision.id]) |> fetch + signals <- query @OutcomeSignal + |> filterWhereIn (#widgetId, widgetIds) |> fetch + length deployments `shouldBe` 1 + length signals `shouldBe` 2 + let improvedCount = length (filter (\s -> s.signalType == "improved") signals) + improvedCount `shouldBe` 1 + deleteRecord hub diff --git a/Web/Controller/DecisionRecords.hs b/Web/Controller/DecisionRecords.hs index ded4851..911ea11 100644 --- a/Web/Controller/DecisionRecords.hs +++ b/Web/Controller/DecisionRecords.hs @@ -34,26 +34,36 @@ instance Controller DecisionRecordsController where render IndexView { records, requirements, users, mOutcomeFilter } action ShowDecisionRecordAction { decisionRecordId } = do - record <- fetch decisionRecordId - policyRefs <- query @PolicyReference + record <- fetch decisionRecordId + policyRefs <- query @PolicyReference |> filterWhere (#decisionId, decisionRecordId) |> orderByAsc #createdAt |> fetch - implRefs <- query @ImplementationChangeReference + implRefs <- query @ImplementationChangeReference |> filterWhere (#decisionId, decisionRecordId) |> orderByAsc #linkedAt |> fetch - mRequirement <- case record.requirementId of + deploymentRecords <- query @DeploymentRecord + |> filterWhere (#decisionId, decisionRecordId) + |> orderByDesc #deployedAt + |> fetch + let deploymentIds = map (.id) deploymentRecords + evaluations <- query @ChangeEvaluation + |> filterWhereIn (#deploymentId, deploymentIds) + |> fetch + mRequirement <- case record.requirementId of Nothing -> pure Nothing Just rid -> fetchOneOrNothing rid - mCandidate <- case record.candidateId of + mCandidate <- case record.candidateId of Nothing -> pure Nothing Just cid -> fetchOneOrNothing cid - users <- query @User |> fetch + users <- query @User |> fetch render ShowView { record , policyRefs , implRefs + , deploymentRecords + , evaluations , mRequirement , mCandidate , users diff --git a/Web/Controller/DeploymentRecords.hs b/Web/Controller/DeploymentRecords.hs new file mode 100644 index 0000000..5849d89 --- /dev/null +++ b/Web/Controller/DeploymentRecords.hs @@ -0,0 +1,180 @@ +module Web.Controller.DeploymentRecords where + +import Web.Types +import Web.View.DeploymentRecords.Index +import Web.View.DeploymentRecords.Show +import Web.View.DeploymentRecords.New +import Generated.Types +import IHP.Prelude +import IHP.ControllerPrelude +import Data.Time.Clock (addUTCTime, NominalDiffTime) +import Text.Read (readMaybe) +import Data.String.Conversions (cs) + +instance Controller DeploymentRecordsController where + beforeAction = ensureIsUser + + action DeploymentRecordsAction = do + records <- query @DeploymentRecord |> orderByDesc #deployedAt |> fetch + decisions <- query @DecisionRecord |> fetch + signals <- query @OutcomeSignal |> fetch + evaluations <- query @ChangeEvaluation |> fetch + render IndexView { records, decisions, signals, evaluations } + + action ShowDeploymentRecordAction { deploymentRecordId } = do + record <- fetch deploymentRecordId + decision <- fetch record.decisionId + mImplRef <- case record.implRefId of + Nothing -> pure Nothing + Just rid -> fetchOneOrNothing rid + mRequirement <- case decision.requirementId of + Nothing -> pure Nothing + Just rid -> fetchOneOrNothing rid + mCandidate <- case decision.candidateId of + Nothing -> pure Nothing + Just cid -> fetchOneOrNothing cid + mWidget <- case mCandidate of + Nothing -> pure Nothing + Just c -> fetchOneOrNothing c.sourceWidgetId + signals <- query @OutcomeSignal + |> filterWhere (#deploymentId, deploymentRecordId) + |> orderByDesc #observedAt + |> fetch + mEvaluation <- query @ChangeEvaluation + |> filterWhere (#deploymentId, deploymentRecordId) + |> fetchOneOrNothing + users <- query @User |> fetch + comparison <- computeComparison record.deployedAt mWidget + render ShowView + { record + , decision + , mImplRef + , mRequirement + , mCandidate + , mWidget + , signals + , mEvaluation + , users + , comparison + } + + action NewDeploymentRecordAction = do + decisions <- query @DecisionRecord |> fetch + implRefs <- query @ImplementationChangeReference |> fetch + users <- query @User |> fetch + mDecisionId <- paramOrNothing @(Id DecisionRecord) "decisionId" + let record = newRecord @DeploymentRecord + render NewView { record, decisions, implRefs, users, mDecisionId } + + action CreateDeploymentRecordAction = do + decisions <- query @DecisionRecord |> fetch + implRefs <- query @ImplementationChangeReference |> fetch + users <- query @User |> fetch + mUser <- currentUserOrNothing + let deployedBy = fmap (.id) mUser + + let record = newRecord @DeploymentRecord + record + |> fill @'["decisionId", "implRefId", "versionRef", "notes"] + |> set #deployedBy (fmap (Id . unId) deployedBy) + |> validateField #versionRef nonEmpty + |> ifValid \case + Left r -> render NewView { record = r, decisions, implRefs, users, mDecisionId = Just r.decisionId } + Right r -> do + created <- createRecord r + setSuccessMessage "Deployment record created" + redirectTo ShowDeploymentRecordAction { deploymentRecordId = created.id } + + action RecordOutcomeSignalAction { deploymentRecordId } = do + signalType <- param @Text "signalType" + mValue <- paramOrNothing @Double "value" + mUser <- currentUserOrNothing + let validTypes = ["improved", "regressed", "neutral", "inconclusive"] :: [Text] + unless (signalType `elem` validTypes) do + setErrorMessage ("Invalid signal type: " <> signalType) + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + -- Fetch the deployment to get the widget_id from its decision → candidate chain + deployment <- fetch deploymentRecordId + decision <- fetch deployment.decisionId + mCandidate <- case decision.candidateId of + Nothing -> pure Nothing + Just cid -> fetchOneOrNothing cid + case mCandidate of + Nothing -> do + setErrorMessage "Cannot record signal: no widget linked to this deployment's decision" + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + Just candidate -> do + newRecord @OutcomeSignal + |> set #deploymentId deploymentRecordId + |> set #widgetId candidate.sourceWidgetId + |> set #signalType signalType + |> set #value mValue + |> createRecord + setSuccessMessage ("Outcome signal recorded: " <> signalType) + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + + action EvaluateChangeAction { deploymentRecordId } = do + -- Idempotent: if already evaluated, redirect with message + existing <- query @ChangeEvaluation + |> filterWhere (#deploymentId, deploymentRecordId) + |> fetchOneOrNothing + case existing of + Just _ -> do + setErrorMessage "Already evaluated — one evaluation per deployment." + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + Nothing -> do + mUser <- currentUserOrNothing + let evaluatedBy = fmap (.id) mUser + scoreText <- param @Text "score" + rationale <- param @Text "rationale" + let mScore = readMaybe (cs scoreText) :: Maybe Int + case mScore of + Nothing -> do + setErrorMessage "Score must be a number between 1 and 5" + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + Just s | s < 1 || s > 5 -> do + setErrorMessage "Score must be between 1 and 5" + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + Just s -> do + when (rationale == "") do + setErrorMessage "Rationale cannot be empty" + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + deployment <- fetch deploymentRecordId + newRecord @ChangeEvaluation + |> set #deploymentId deploymentRecordId + |> set #decisionId (Just deployment.decisionId) + |> set #score (fromIntegral s) + |> set #rationale rationale + |> set #evaluatedBy (fmap (Id . unId) evaluatedBy) + |> createRecord + setSuccessMessage "Change evaluated" + redirectTo ShowDeploymentRecordAction { deploymentRecordId } + +thirtyDays :: NominalDiffTime +thirtyDays = 30 * 24 * 3600 + +computeComparison :: (?modelContext :: ModelContext) => UTCTime -> Maybe Widget -> IO (Maybe (PeriodMetrics, PeriodMetrics)) +computeComparison _ Nothing = pure Nothing +computeComparison deployedAt (Just w) = do + let beforeStart = addUTCTime (negate thirtyDays) deployedAt + afterEnd = addUTCTime thirtyDays deployedAt + allEvents <- query @InteractionEvent |> filterWhere (#widgetId, w.id) |> fetch + allAnnotations <- query @Annotation |> filterWhere (#widgetId, w.id) |> fetch + let inWindow s e t = t >= s && t < e + evBefore = filter (\x -> inWindow beforeStart deployedAt x.occurredAt) allEvents + evAfter = filter (\x -> inWindow deployedAt afterEnd x.occurredAt) allEvents + annBefore = filter (\x -> inWindow beforeStart deployedAt x.createdAt && isNothing x.retractedAt) allAnnotations + annAfter = filter (\x -> inWindow deployedAt afterEnd x.createdAt && isNothing x.retractedAt) allAnnotations + pure $ Just (buildMetrics evBefore annBefore, buildMetrics evAfter annAfter) + +buildMetrics :: [InteractionEvent] -> [Annotation] -> PeriodMetrics +buildMetrics events anns = PeriodMetrics + { eventCount = length events + , annotationCount = length anns + , lowCount = count "low" + , mediumCount = count "medium" + , highCount = count "high" + , criticalCount = count "critical" + } + where + count sev = length (filter (\a -> a.severity == sev) anns) diff --git a/Web/Controller/Hubs.hs b/Web/Controller/Hubs.hs index eacbf71..15ad0ba 100644 --- a/Web/Controller/Hubs.hs +++ b/Web/Controller/Hubs.hs @@ -7,9 +7,11 @@ import Web.View.Hubs.New import Web.View.Hubs.Edit import Web.View.Hubs.TriageDashboard import Web.View.Hubs.GovernanceDashboard +import Web.View.Hubs.AntifragilityDashboard import Generated.Types import IHP.Prelude import IHP.ControllerPrelude +import Application.Helper.Controller (regressedWidgetIds, widgetCycleCounts) instance Controller HubsController where beforeAction = ensureIsUser @@ -147,6 +149,13 @@ instance Controller HubsController where |> filterWhereIn (#widgetId, widgetIds) |> fetch + -- Outcome signals for regression detection + allSignals <- query @OutcomeSignal + |> filterWhereIn (#widgetId, widgetIds) + |> fetch + + let regressionWidgetIds = regressedWidgetIds allSignals allAnnotations + render GovernanceDashboardView { hub , widgets @@ -155,4 +164,58 @@ instance Controller HubsController where , recentDecisions , allDecisions , allAnnotations + , regressionWidgetIds + } + + action AntifragilityDashboardAction { hubId } = autoRefresh do + hub <- fetch hubId + widgets <- query @Widget + |> filterWhere (#hubId, hubId) + |> fetch + let widgetIds = map (.id) widgets + + -- Deployments for this hub's decisions + allCandidates <- query @RequirementCandidate + |> filterWhereIn (#sourceWidgetId, widgetIds) + |> fetch + let acceptedCandidateIds = map (.id) (filter (\c -> c.status == "accepted") allCandidates) + allRequirements <- query @Requirement + |> filterWhereIn (#sourceCandidateId, acceptedCandidateIds) + |> fetch + let requirementIds = map (.id) allRequirements + allDecisions <- query @DecisionRecord + |> filterWhereIn (#requirementId, map Just requirementIds) + |> fetch + let decisionIds = map (.id) allDecisions + allDeployments <- query @DeploymentRecord + |> filterWhereIn (#decisionId, decisionIds) + |> orderByDesc #deployedAt + |> fetch + let deploymentIds = map (.id) allDeployments + allSignals <- query @OutcomeSignal + |> filterWhereIn (#widgetId, widgetIds) + |> fetch + allEvaluations <- query @ChangeEvaluation + |> filterWhereIn (#deploymentId, deploymentIds) + |> fetch + allImplRefs <- query @ImplementationChangeReference + |> filterWhereIn (#decisionId, decisionIds) + |> fetch + allAnnotations <- query @Annotation + |> filterWhereIn (#widgetId, widgetIds) + |> fetch + + let regressionWidgetIds = regressedWidgetIds allSignals allAnnotations + recurrenceLeaderboard = take 10 (widgetCycleCounts allCandidates allRequirements allDecisions allDeployments) + + render AntifragilityDashboardView + { hub + , widgets + , allDeployments + , allDecisions + , allSignals + , allEvaluations + , allImplRefs + , regressionWidgetIds + , recurrenceLeaderboard } diff --git a/Web/Controller/Widgets.hs b/Web/Controller/Widgets.hs index d63aa08..6eafa54 100644 --- a/Web/Controller/Widgets.hs +++ b/Web/Controller/Widgets.hs @@ -9,6 +9,7 @@ import Generated.Types import IHP.Prelude import IHP.ControllerPrelude import Data.Aeson (toJSON, object, (.=)) +import Application.Helper.Controller (isInRegression, widgetCycleCounts) instance Controller WidgetsController where beforeAction = ensureIsUser @@ -39,7 +40,23 @@ instance Controller WidgetsController where |> filterWhere (#widgetId, widgetId) |> orderByAsc #createdAt |> fetch - render ShowView { widget, hub, versions, events, annotations } + recentSignals <- query @OutcomeSignal + |> filterWhere (#widgetId, widgetId) + |> orderByDesc #observedAt + |> limit 10 + |> fetch + allSignals <- query @OutcomeSignal + |> filterWhere (#widgetId, widgetId) + |> fetch + let isRegressed = isInRegression allSignals annotations widgetId + -- Recurrence cycle count for this widget + allCandidates <- query @RequirementCandidate |> filterWhere (#sourceWidgetId, widgetId) |> fetch + allRequirements <- query @Requirement |> fetch + allDecisions <- query @DecisionRecord |> fetch + allDeployments <- query @DeploymentRecord |> fetch + let cycleCounts = widgetCycleCounts allCandidates allRequirements allDecisions allDeployments + cycleCount = fromMaybe 0 (lookup widgetId cycleCounts) + render ShowView { widget, hub, versions, events, annotations, recentSignals, isRegressed, cycleCount } action CreateWidgetAction = do let widget = newRecord @Widget diff --git a/Web/FrontController.hs b/Web/FrontController.hs index b074cc5..3cb1709 100644 --- a/Web/FrontController.hs +++ b/Web/FrontController.hs @@ -15,6 +15,7 @@ import Web.Controller.AnnotationThreads () import Web.Controller.RequirementCandidates () import Web.Controller.Requirements () import Web.Controller.DecisionRecords () +import Web.Controller.DeploymentRecords () import Web.Controller.Sessions () instance FrontController WebApplication where @@ -28,6 +29,7 @@ instance FrontController WebApplication where , parseRoute @RequirementCandidatesController , parseRoute @RequirementsController , parseRoute @DecisionRecordsController + , parseRoute @DeploymentRecordsController ] instance InitControllerContext WebApplication where @@ -56,6 +58,7 @@ defaultLayout inner = [hsx| Candidates Requirements Decisions + Deployments
Sign out
diff --git a/Web/Routes.hs b/Web/Routes.hs index 8ede9d9..2b40237 100644 --- a/Web/Routes.hs +++ b/Web/Routes.hs @@ -28,5 +28,8 @@ instance AutoRoute RequirementsController -- Decision Records (Phase 3) instance AutoRoute DecisionRecordsController +-- Deployment Records (Phase 4) +instance AutoRoute DeploymentRecordsController + -- Sessions instance AutoRoute SessionsController diff --git a/Web/Types.hs b/Web/Types.hs index 5dd826f..0d3ec1c 100644 --- a/Web/Types.hs +++ b/Web/Types.hs @@ -23,8 +23,9 @@ data HubsController | EditHubAction { hubId :: !(Id Hub) } | UpdateHubAction { hubId :: !(Id Hub) } | DeleteHubAction { hubId :: !(Id Hub) } - | TriageDashboardAction { hubId :: !(Id Hub) } - | GovernanceDashboardAction { hubId :: !(Id Hub) } + | TriageDashboardAction { hubId :: !(Id Hub) } + | GovernanceDashboardAction { hubId :: !(Id Hub) } + | AntifragilityDashboardAction { hubId :: !(Id Hub) } deriving (Eq, Show, Data) data WidgetsController @@ -88,6 +89,15 @@ data DecisionRecordsController | DeleteImplementationRefAction { implementationChangeReferenceId :: !(Id ImplementationChangeReference) } deriving (Eq, Show, Data) +data DeploymentRecordsController + = DeploymentRecordsAction + | ShowDeploymentRecordAction { deploymentRecordId :: !(Id DeploymentRecord) } + | NewDeploymentRecordAction + | CreateDeploymentRecordAction + | RecordOutcomeSignalAction { deploymentRecordId :: !(Id DeploymentRecord) } + | EvaluateChangeAction { deploymentRecordId :: !(Id DeploymentRecord) } + deriving (Eq, Show, Data) + data SessionsController = NewSessionAction | CreateSessionAction diff --git a/Web/View/DecisionRecords/Show.hs b/Web/View/DecisionRecords/Show.hs index fd7d234..1a05042 100644 --- a/Web/View/DecisionRecords/Show.hs +++ b/Web/View/DecisionRecords/Show.hs @@ -6,12 +6,14 @@ import IHP.Prelude import IHP.ViewPrelude data ShowView = ShowView - { record :: !DecisionRecord - , policyRefs :: ![PolicyReference] - , implRefs :: ![ImplementationChangeReference] - , mRequirement :: !(Maybe Requirement) - , mCandidate :: !(Maybe RequirementCandidate) - , users :: ![User] + { record :: !DecisionRecord + , policyRefs :: ![PolicyReference] + , implRefs :: ![ImplementationChangeReference] + , deploymentRecords :: ![DeploymentRecord] + , evaluations :: ![ChangeEvaluation] + , mRequirement :: !(Maybe Requirement) + , mCandidate :: !(Maybe RequirementCandidate) + , users :: ![User] } instance View ShowView where @@ -92,6 +94,24 @@ instance View ShowView where + +
+
+

Deployments

+ {if null implRefs + then mempty + else [hsx| + "?decisionId=" <> show record.id} + class="text-xs border border-indigo-300 text-indigo-600 px-3 py-1 rounded hover:bg-indigo-50"> + New Deployment + + |]} +
+ {if null deploymentRecords + then [hsx|

No deployments recorded yet.

|] + else [hsx|{forEach deploymentRecords (renderDeploymentRow evaluations)}|]} +
+

Implementation References

@@ -201,6 +221,36 @@ systemBadgeClass "linear" = "bg-violet-100 text-violet-800" systemBadgeClass "jira" = "bg-blue-100 text-blue-800" systemBadgeClass _ = "bg-gray-100 text-gray-600" +renderDeploymentRow :: [ChangeEvaluation] -> DeploymentRecord -> Html +renderDeploymentRow evals dr = [hsx| +
+
+ {dr.versionRef} + {show dr.deployedAt} +
+ {maybe mempty renderEvalSummary mEval} +
+|] + where + mEval = find (\e -> e.deploymentId == dr.id) evals + +renderEvalSummary :: ChangeEvaluation -> Html +renderEvalSummary ev = [hsx| + " text-xs px-2 py-0.5 rounded font-medium"}> + {starsFor ev.score} + +|] + +starsFor :: Int16 -> Text +starsFor n = pack (Prelude.replicate (fromIntegral n) '★') <> pack (Prelude.replicate (5 - fromIntegral n) '☆') + +scoreClass :: Int16 -> Text +scoreClass n + | n <= 2 = "bg-red-100 text-red-800" + | n == 3 = "bg-yellow-100 text-yellow-800" + | otherwise = "bg-green-100 text-green-800" + userName :: [User] -> Maybe (Id User) -> Text userName _ Nothing = "—" userName users (Just uid) = maybe "(unknown)" (.name) (find (\u -> u.id == uid) users) diff --git a/Web/View/DeploymentRecords/Index.hs b/Web/View/DeploymentRecords/Index.hs new file mode 100644 index 0000000..b263992 --- /dev/null +++ b/Web/View/DeploymentRecords/Index.hs @@ -0,0 +1,86 @@ +module Web.View.DeploymentRecords.Index where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data IndexView = IndexView + { records :: ![DeploymentRecord] + , decisions :: ![DecisionRecord] + , signals :: ![OutcomeSignal] + , evaluations :: ![ChangeEvaluation] + } + +instance View IndexView where + html IndexView { .. } = [hsx| +
+

Deployments

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

No deployment records yet.

|] + else renderTable records decisions signals evaluations} + |] + +renderTable :: [DeploymentRecord] -> [DecisionRecord] -> [OutcomeSignal] -> [ChangeEvaluation] -> Html +renderTable records decisions signals evaluations = [hsx| +
+ + + + + + + + + + + + {forEach records (renderRow decisions signals evaluations)} + +
DecisionVersionDeployed AtSignalsEvaluation
+
+|] + +renderRow :: [DecisionRecord] -> [OutcomeSignal] -> [ChangeEvaluation] -> DeploymentRecord -> Html +renderRow decisions signals evaluations record = [hsx| + + + {decisionTitle} + + {record.versionRef} + {show record.deployedAt} + {show signalCount} + + {maybe [hsx||] renderScoreBadge mScore} + + +|] + where + decisionTitle = maybe "(unknown)" (.title) $ + find (\d -> d.id == record.decisionId) decisions + signalCount = length $ filter (\s -> s.deploymentId == record.id) signals + mScore :: Maybe Int16 + mScore = fmap (.score) $ find (\e -> e.deploymentId == record.id) evaluations + +renderScoreBadge :: Int16 -> Html +renderScoreBadge score = [hsx| + " text-xs px-2 py-0.5 rounded font-medium"}> + {starsFor score} + +|] + +starsFor :: Int16 -> Text +starsFor n = pack (Prelude.replicate (fromIntegral n) '★') <> pack (Prelude.replicate (5 - fromIntegral n) '☆') + +scoreClass :: Int16 -> Text +scoreClass n + | n <= 2 = "bg-red-100 text-red-800" + | n == 3 = "bg-yellow-100 text-yellow-800" + | otherwise = "bg-green-100 text-green-800" diff --git a/Web/View/DeploymentRecords/New.hs b/Web/View/DeploymentRecords/New.hs new file mode 100644 index 0000000..bdbbade --- /dev/null +++ b/Web/View/DeploymentRecords/New.hs @@ -0,0 +1,99 @@ +module Web.View.DeploymentRecords.New where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data NewView = NewView + { record :: !DeploymentRecord + , decisions :: ![DecisionRecord] + , implRefs :: ![ImplementationChangeReference] + , users :: ![User] + , mDecisionId :: !(Maybe (Id DecisionRecord)) + } + +instance View NewView where + html NewView { .. } = [hsx| +
+ Deployments + / + New +
+ +
+

Record Deployment

+ +
+ {hiddenField "authenticity_token"} + +
+ + +
+ +
+ + +
+ +
+ + + {validationErrorsFor record #versionRef} +
+ +
+ + +
+ +
+ + + Cancel + +
+
+
+ |] + +renderDecisionOption :: Maybe (Id DecisionRecord) -> DecisionRecord -> Html +renderDecisionOption mSelected d = [hsx| + +|] + where + isSelected = case mSelected of + Just sid -> sid == d.id + Nothing -> False + +renderImplRefOption :: ImplementationChangeReference -> Html +renderImplRefOption ref = [hsx| + +|] diff --git a/Web/View/DeploymentRecords/Show.hs b/Web/View/DeploymentRecords/Show.hs new file mode 100644 index 0000000..b3771bb --- /dev/null +++ b/Web/View/DeploymentRecords/Show.hs @@ -0,0 +1,332 @@ +module Web.View.DeploymentRecords.Show where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data PeriodMetrics = PeriodMetrics + { eventCount :: !Int + , annotationCount :: !Int + , lowCount :: !Int + , mediumCount :: !Int + , highCount :: !Int + , criticalCount :: !Int + } + +highCriticalRate :: PeriodMetrics -> Double +highCriticalRate m + | m.annotationCount == 0 = 0 + | otherwise = fromIntegral (m.highCount + m.criticalCount) / fromIntegral m.annotationCount + +data ShowView = ShowView + { record :: !DeploymentRecord + , decision :: !DecisionRecord + , mImplRef :: !(Maybe ImplementationChangeReference) + , mRequirement :: !(Maybe Requirement) + , mCandidate :: !(Maybe RequirementCandidate) + , mWidget :: !(Maybe Widget) + , signals :: ![OutcomeSignal] + , mEvaluation :: !(Maybe ChangeEvaluation) + , users :: ![User] + , comparison :: !(Maybe (PeriodMetrics, PeriodMetrics)) + } + +instance View ShowView where + html ShowView { .. } = [hsx| +
+ Deployments + / + {record.versionRef} +
+ +
+ +
+
+

{record.versionRef}

+
+
+ Deployed at: {show record.deployedAt} · + Deployed by: {userName users record.deployedBy} +
+ {maybe mempty renderNotes record.notes} +
+ + +
+

Decision Chain

+
+
+ Decision + {decision.title} + " text-xs px-2 py-0.5 rounded font-medium"}> + {decision.outcome} + +
+ {maybe mempty renderImplRefRow mImplRef} + {maybe mempty renderRequirementRow mRequirement} + {maybe mempty renderCandidateRow mCandidate} + {maybe mempty renderWidgetRow mWidget} +
+
+ + +
+

Outcome Signals

+ {if null signals + then [hsx|

No signals recorded yet.

|] + else [hsx|
{forEach signals renderSignal}
|]} +
+ {hiddenField "authenticity_token"} +
+ + +
+
+ + +
+ +
+
+ + +
+

Change Evaluation

+ {maybe (renderNoEvaluationForm record.id) renderEvaluation mEvaluation} +
+ + + {maybe mempty renderComparison comparison} +
+ |] + +renderNotes :: Text -> Html +renderNotes notes = [hsx| +
+

Notes

+

{notes}

+
+|] + +renderImplRefRow :: ImplementationChangeReference -> Html +renderImplRefRow ref = [hsx| +
+ Impl Ref + {ref.workItemRef} + ({ref.system}) +
+|] + +renderRequirementRow :: Requirement -> Html +renderRequirementRow req = [hsx| +
+ Requirement + {req.title} +
+|] + +renderCandidateRow :: RequirementCandidate -> Html +renderCandidateRow c = [hsx| +
+ Candidate + {c.title} +
+|] + +renderWidgetRow :: Widget -> Html +renderWidgetRow w = [hsx| +
+ Widget + {w.name} +
+|] + +renderSignal :: OutcomeSignal -> Html +renderSignal sig = [hsx| +
+ " text-xs px-2 py-0.5 rounded font-medium"}> + {sig.signalType} + + {maybe mempty renderSignalValue sig.value} + {show sig.observedAt} +
+|] + +renderSignalValue :: Double -> Html +renderSignalValue v = [hsx| + {show v} +|] + +renderNoEvaluationForm :: Id DeploymentRecord -> Html +renderNoEvaluationForm deploymentRecordId = [hsx| +
+ {hiddenField "authenticity_token"} +
+ + +
+
+ + +
+ +
+|] + +renderEvaluation :: ChangeEvaluation -> Html +renderEvaluation ev = [hsx| +
+
+ " text-base px-2 py-0.5 rounded font-medium"}> + {starsFor ev.score} + +
+

{ev.rationale}

+

{show ev.evaluatedAt}

+
+|] + +renderComparison :: (PeriodMetrics, PeriodMetrics) -> Html +renderComparison (before, after) = [hsx| +
+

+ Pre/Post Comparison (±30 days) +

+ + + + + + + + + + + {renderMetricRow "Interaction events" before.eventCount after.eventCount False} + {renderMetricRow "Annotations (total)" before.annotationCount after.annotationCount True} + {renderMetricRow "— low severity" before.lowCount after.lowCount True} + {renderMetricRow "— medium severity" before.mediumCount after.mediumCount True} + {renderMetricRow "— high severity" before.highCount after.highCount True} + {renderMetricRow "— critical severity" before.criticalCount after.criticalCount True} + {renderRateRow before after} + +
MetricBeforeAfterDelta
+
+|] + +renderMetricRow :: Text -> Int -> Int -> Bool -> Html +renderMetricRow label b a lowerIsBetter = [hsx| + + {label} + {showNA b} + {showNA a} + + {showDelta (a - b)} + + +|] + where + showNA n = if n == 0 then "—" else show n + showDelta d + | d == 0 = "—" + | d > 0 = "+" <> show d + | otherwise = show d + +renderRateRow :: PeriodMetrics -> PeriodMetrics -> Html +renderRateRow before after = [hsx| + + High/critical rate + {formatRate (highCriticalRate before)} + {formatRate (highCriticalRate after)} + + + {formatRateDelta (highCriticalRate after - highCriticalRate before)} + + + +|] + where + formatRate r = show (round (r * 100) :: Int) <> "%" + formatRateDelta d + | abs d < 0.001 = "—" + | d > 0 = "+" <> show (round (d * 100) :: Int) <> "%" + | otherwise = show (round (d * 100) :: Int) <> "%" + +deltaClass :: Int -> Bool -> Text +deltaClass 0 _ = "text-gray-400" +deltaClass d True + | d < 0 = "text-green-600 font-medium" + | otherwise = "text-red-600 font-medium" +deltaClass d False + | d > 0 = "text-green-600 font-medium" + | otherwise = "text-red-600 font-medium" + +rateClass :: Double -> Double -> Text +rateClass after before + | abs (after - before) < 0.001 = "text-gray-400" + | after < before = "text-green-600 font-medium" + | otherwise = "text-red-600 font-medium" + +signalTypeClass :: Text -> Text +signalTypeClass "improved" = "bg-green-100 text-green-800" +signalTypeClass "regressed" = "bg-red-100 text-red-800" +signalTypeClass "neutral" = "bg-gray-100 text-gray-600" +signalTypeClass "inconclusive" = "bg-yellow-100 text-yellow-800" +signalTypeClass _ = "bg-gray-100 text-gray-600" + +outcomeClass :: Text -> Text +outcomeClass "accepted" = "bg-green-100 text-green-800" +outcomeClass "rejected" = "bg-red-100 text-red-800" +outcomeClass "deferred" = "bg-gray-100 text-gray-600" +outcomeClass "split" = "bg-purple-100 text-purple-800" +outcomeClass "merged" = "bg-indigo-100 text-indigo-800" +outcomeClass "reframed" = "bg-orange-100 text-orange-800" +outcomeClass _ = "bg-gray-100 text-gray-600" + +scoreClass :: Int16 -> Text +scoreClass n + | n <= 2 = "bg-red-100 text-red-800" + | n == 3 = "bg-yellow-100 text-yellow-800" + | otherwise = "bg-green-100 text-green-800" + +starsFor :: Int16 -> Text +starsFor n = pack (Prelude.replicate (fromIntegral n) '★') <> pack (Prelude.replicate (5 - fromIntegral n) '☆') + +userName :: [User] -> Maybe (Id User) -> Text +userName _ Nothing = "—" +userName users (Just uid) = maybe "(unknown)" (.name) (find (\u -> u.id == uid) users) diff --git a/Web/View/Hubs/AntifragilityDashboard.hs b/Web/View/Hubs/AntifragilityDashboard.hs new file mode 100644 index 0000000..ce5ac9f --- /dev/null +++ b/Web/View/Hubs/AntifragilityDashboard.hs @@ -0,0 +1,259 @@ +module Web.View.Hubs.AntifragilityDashboard where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data AntifragilityDashboardView = AntifragilityDashboardView + { hub :: !Hub + , widgets :: ![Widget] + , allDeployments :: ![DeploymentRecord] + , allDecisions :: ![DecisionRecord] + , allSignals :: ![OutcomeSignal] + , allEvaluations :: ![ChangeEvaluation] + , allImplRefs :: ![ImplementationChangeReference] + , regressionWidgetIds :: ![Id Widget] + , recurrenceLeaderboard :: ![(Id Widget, Int)] + } + +instance View AntifragilityDashboardView where + html AntifragilityDashboardView { .. } = [hsx| +
+
+
+ Hubs + / + {hub.name} + / + Antifragility +
+

Antifragility Dashboard — {hub.name}

+
+ +
+ + +
+
+
{show (length allDeployments)}
+
deployments
+
+
+
{avgScoreText}
+
avg evaluation
+
+
+
{improvedPctText}
+
improved signals
+
+
+
{show (length regressionWidgetIds)}
+
regressions
+
+
+ + + {if null regressionWidgetIds then mempty else [hsx| +
+

⚠ Regression Alerts

+
+ {forEach regressedWidgets renderRegressedBadge} +
+
+ |]} + + +
+

+ Open Gaps + + (decisions with impl refs but no deployment recorded) + +

+ {if null openGaps + then [hsx|

All decisions with impl refs have deployments.

|] + else [hsx| +
+ {forEach openGaps renderGapRow} +
+ |]} +
+ + +
+

Recent Deployments

+ {if null recentDeploys + then [hsx|

No deployments yet.

|] + else [hsx| + + + + + + + + + + + + {forEach recentDeploys (renderDeployRow allDecisions allSignals allEvaluations)} + +
VersionDecisionSignalsEvalDeployed
+ |]} +
+ + +
+

Recurrence Leaderboard

+ {if null recurrenceLeaderboard + then [hsx|

No recurring widgets detected.

|] + else [hsx| + + + + + + + + + {forEach recurrenceLeaderboard (renderRecurrenceRow widgets)} + +
WidgetCycles
+ |]} +
+ |] + where + deployedIds = map (.id) allDeployments + openGaps = filter (\d -> any (\r -> r.decisionId == d.id) allImplRefs + && not (any (\dp -> dp.decisionId == d.id) allDeployments)) + allDecisions + recentDeploys = take 20 (sortByDesc (.deployedAt) allDeployments) + regressedWidgets = filter (\w -> w.id `elem` regressionWidgetIds) widgets + avgScoreText + | null allEvaluations = "—" + | otherwise = + let avg = fromIntegral (sum (map (.score) allEvaluations)) / fromIntegral (length allEvaluations) :: Double + in show (round avg :: Int) <> "/5" + improvedPctText + | null allSignals = "—" + | otherwise = + let improved = length (filter (\s -> s.signalType == "improved") allSignals) + pct = (fromIntegral improved * 100 `div` length allSignals) :: Int + in show pct <> "%" + +sortByDesc :: Ord b => (a -> b) -> [a] -> [a] +sortByDesc f = sortBy (\a b -> compare (f b) (f a)) + +renderRegressedBadge :: Widget -> Html +renderRegressedBadge w = [hsx| + + {w.name} + +|] + +renderGapRow :: DecisionRecord -> Html +renderGapRow d = [hsx| +
+ {d.title} + " text-xs px-2 py-0.5 rounded font-medium"}> + {d.outcome} + +
+|] + +renderDeployRow :: [DecisionRecord] -> [OutcomeSignal] -> [ChangeEvaluation] -> DeploymentRecord -> Html +renderDeployRow decisions signals evals dr = [hsx| + + + {dr.versionRef} + + {decisionTitle} + + {renderSignalSummary drSignals} + + + {maybe [hsx||] renderEvalBadge mScore} + + {show dr.deployedAt} + +|] + where + decisionTitle = maybe "—" (.title) (find (\d -> d.id == dr.decisionId) decisions) + drSignals = filter (\s -> s.deploymentId == dr.id) signals + mScore = fmap (.score) (find (\e -> e.deploymentId == dr.id) evals) + +renderSignalSummary :: [OutcomeSignal] -> Html +renderSignalSummary [] = [hsx||] +renderSignalSummary signals = [hsx| +
+ {forEach (take 3 signals) (\s -> [hsx| + + |])} +
+|] + +signalDot :: Text -> Text +signalDot "improved" = "inline-block w-2 h-2 rounded-full bg-green-500" +signalDot "regressed" = "inline-block w-2 h-2 rounded-full bg-red-500" +signalDot "neutral" = "inline-block w-2 h-2 rounded-full bg-gray-400" +signalDot "inconclusive" = "inline-block w-2 h-2 rounded-full bg-yellow-400" +signalDot _ = "inline-block w-2 h-2 rounded-full bg-gray-300" + +renderEvalBadge :: Int16 -> Html +renderEvalBadge score = [hsx| + " text-xs px-2 py-0.5 rounded font-medium"}> + {show score}/5 + +|] + +renderRecurrenceRow :: [Widget] -> (Id Widget, Int) -> Html +renderRecurrenceRow widgets (wid, count) = [hsx| + + + {maybe [hsx||] renderWidgetLink mWidget} + + + ⟳ {show count} + + +|] + where + mWidget = find (\w -> w.id == wid) widgets + +renderWidgetLink :: Widget -> Html +renderWidgetLink w = [hsx| + {w.name} +|] + +outcomeClass :: Text -> Text +outcomeClass "accepted" = "bg-green-100 text-green-800" +outcomeClass "rejected" = "bg-red-100 text-red-800" +outcomeClass "deferred" = "bg-gray-100 text-gray-600" +outcomeClass "split" = "bg-purple-100 text-purple-800" +outcomeClass "merged" = "bg-indigo-100 text-indigo-800" +outcomeClass "reframed" = "bg-orange-100 text-orange-800" +outcomeClass _ = "bg-gray-100 text-gray-600" + +scoreClass :: Int16 -> Text +scoreClass n + | n <= 2 = "bg-red-100 text-red-800" + | n == 3 = "bg-yellow-100 text-yellow-800" + | otherwise = "bg-green-100 text-green-800" diff --git a/Web/View/Hubs/GovernanceDashboard.hs b/Web/View/Hubs/GovernanceDashboard.hs index 3c9b286..8d20345 100644 --- a/Web/View/Hubs/GovernanceDashboard.hs +++ b/Web/View/Hubs/GovernanceDashboard.hs @@ -6,13 +6,14 @@ import IHP.Prelude import IHP.ViewPrelude data GovernanceDashboardView = GovernanceDashboardView - { hub :: !Hub - , widgets :: ![Widget] - , allCandidates :: ![RequirementCandidate] - , allRequirements :: ![Requirement] - , recentDecisions :: ![DecisionRecord] - , allDecisions :: ![DecisionRecord] - , allAnnotations :: ![Annotation] + { hub :: !Hub + , widgets :: ![Widget] + , allCandidates :: ![RequirementCandidate] + , allRequirements :: ![Requirement] + , recentDecisions :: ![DecisionRecord] + , allDecisions :: ![DecisionRecord] + , allAnnotations :: ![Annotation] + , regressionWidgetIds :: ![Id Widget] } instance View GovernanceDashboardView where @@ -33,6 +34,10 @@ instance View GovernanceDashboardView where class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50"> Triage Dashboard + + Antifragility + Hub Overview @@ -40,11 +45,24 @@ instance View GovernanceDashboardView where
- -
+ +
{forEach outcomeList (\o -> renderKpiCard o (countOutcome allDecisions o))} +
+
{show (length regressionWidgetIds)}
+
regressions
+
+ {if null regressionWidgetIds then mempty else [hsx| +
+

⚠ Regressed Widgets

+
+ {forEach regressedWidgets renderRegressedBadge} +
+
+ |]} +

@@ -99,7 +117,8 @@ instance View GovernanceDashboardView where

|] where - awaitingDecision = filter (isAwaitingDecision allDecisions) allRequirements + awaitingDecision = filter (isAwaitingDecision allDecisions) allRequirements + regressedWidgets = filter (\w -> w.id `elem` regressionWidgetIds) widgets outcomeList :: [Text] outcomeList = ["accepted", "rejected", "deferred", "split", "merged", "reframed"] @@ -192,6 +211,14 @@ renderCoverageRow annotations candidates requirements decisions w = [hsx| widgetReqIds = map (.id) (filter (\r -> r.sourceCandidateId `elem` candidateIds) requirements) hasDecision = any (\d -> d.requirementId `elem` map Just widgetReqIds) decisions +renderRegressedBadge :: Widget -> Html +renderRegressedBadge w = [hsx| +
+ {w.name} + +|] + coverageMark :: Bool -> Html coverageMark True = [hsx||] coverageMark False = [hsx||] diff --git a/Web/View/Hubs/Show.hs b/Web/View/Hubs/Show.hs index 7aa6f50..a939ffe 100644 --- a/Web/View/Hubs/Show.hs +++ b/Web/View/Hubs/Show.hs @@ -37,6 +37,10 @@ instance View ShowView where class="text-sm border border-purple-300 text-purple-700 px-3 py-1.5 rounded hover:bg-purple-50"> Governance Dashboard + + Antifragility + Edit diff --git a/Web/View/Widgets/Show.hs b/Web/View/Widgets/Show.hs index f3df956..0d42e9c 100644 --- a/Web/View/Widgets/Show.hs +++ b/Web/View/Widgets/Show.hs @@ -7,11 +7,14 @@ import IHP.ViewPrelude import Application.Helper.View (widgetEnvelope) data ShowView = ShowView - { widget :: !Widget - , hub :: !Hub - , versions :: ![WidgetVersion] - , events :: ![InteractionEvent] - , annotations :: ![Annotation] + { widget :: !Widget + , hub :: !Hub + , versions :: ![WidgetVersion] + , events :: ![InteractionEvent] + , annotations :: ![Annotation] + , recentSignals :: ![OutcomeSignal] + , isRegressed :: !Bool + , cycleCount :: !Int } instance View ShowView where @@ -24,6 +27,24 @@ instance View ShowView where {widget.name}
+ {if cycleCount >= 2 then [hsx| +
+ ⟳ {show cycleCount} cycles + + Recurring friction — this widget has been through {show cycleCount} improvement cycles. + +
+ |] else mempty} + + {if isRegressed then [hsx| +
+ ⚠ Regression detected + + This widget had an improved signal but has since received high/critical annotations. + +
+ |] else mempty} + {widgetEnvelope widget [hsx|
@@ -95,6 +116,15 @@ instance View ShowView where
+ {if null recentSignals then mempty else [hsx| +
+

Recent Outcome Signals

+
+ {forEach recentSignals renderSignalRow} +
+
+ |]} +

Version History

@@ -160,3 +190,21 @@ renderCategoryRow (cat, count) = [hsx| {show count}
|] + +renderSignalRow :: OutcomeSignal -> Html +renderSignalRow sig = [hsx| +
+ " text-xs px-2 py-0.5 rounded font-medium"}> + {sig.signalType} + + {maybe mempty (\v -> [hsx|{show v}|]) sig.value} + {show sig.observedAt} +
+|] + +signalTypeClass :: Text -> Text +signalTypeClass "improved" = "bg-green-100 text-green-800" +signalTypeClass "regressed" = "bg-red-100 text-red-800" +signalTypeClass "neutral" = "bg-gray-100 text-gray-600" +signalTypeClass "inconclusive" = "bg-yellow-100 text-yellow-800" +signalTypeClass _ = "bg-gray-100 text-gray-600" diff --git a/docs/phase4-summary.md b/docs/phase4-summary.md new file mode 100644 index 0000000..2ccb294 --- /dev/null +++ b/docs/phase4-summary.md @@ -0,0 +1,81 @@ +# Phase 4 Summary — Outcome Observation and Antifragility Loop + +**Workplan:** IHUB-WP-0004 +**Completed:** 2026-03-29 +**Phase:** 4 of 8 in the IHF specification + +--- + +## What Was Built + +Phase 4 closes the IHF improvement loop by connecting deployed versions to observed outcomes. The full chain is now traversable: + +``` +Widget → InteractionEvent / Annotation + → RequirementCandidate → Requirement + → DecisionRecord → ImplementationChangeReference + → DeploymentRecord + → OutcomeSignal ← ChangeEvaluation + → RegressionDetection / RecurrenceTracking +``` + +### T01 — Schema + +Three new tables: +- **`deployment_records`** — immutable link from a decision to a deployed version (`version_ref`, `deployed_at`, `deployed_by`, `notes`) +- **`outcome_signals`** — append-only observations of widget behaviour post-deployment (`signal_type`, `value`, `observed_at`); PostgreSQL trigger prevents UPDATE/DELETE +- **`change_evaluations`** — one score (1–5) per deployment with rationale; UNIQUE constraint on `deployment_id` + +### T02 — DeploymentRecords Controller and Views + +`DeploymentRecordsController` with index, show, new, create (no update/delete — immutable by convention). Decision show page includes a "New Deployment" button (gated on having at least one implementation reference). Deployment show page renders the full decision chain: Decision → ImplRef → Requirement → Candidate → Widget. + +### T03 — OutcomeSignal Capture + +`RecordOutcomeSignalAction` (POST from deployment show page). Signal types: `improved`, `regressed`, `neutral`, `inconclusive` with color coding (green/red/gray/yellow). Widget show page lists the last 10 signals across all deployments. + +### T04 — Pre/Post Comparison + +Comparison panel on the deployment show page. Computes interaction event counts and annotation severity distribution for the 30-day window before vs. after `deployed_at`. Delta column: green if annotation rate decreased, red if increased. Works with no post-deployment data (shows "—"). + +### T05 — Regression Detection + +`regressedWidgetIds` pure function in `Application.Helper.Controller`. A regression is: any widget with an `OutcomeSignal(improved)` followed (> 1 day later) by a new `Annotation(severity=high|critical)`. Regression badge on widget show page; regression count and widget list on governance dashboard; prominent alerts panel on antifragility dashboard. + +### T06 — ChangeEvaluation + +`EvaluateChangeAction` (POST). Idempotent — second attempt on same deployment redirects with "Already evaluated" message. Score 1–5 rendered as ★ stars with color (1–2 red, 3 yellow, 4–5 green). Evaluation summary shown on decision show page alongside each deployment row. + +### T07 — Recurrence Tracking + +`widgetCycleCounts` function computes completed improvement cycles per widget (cycle = accepted candidate → requirement → decision → deployment → new candidate). Cycle count badge ("⟳ N cycles") on widget show page for cycle_count ≥ 2. Top-10 leaderboard on antifragility dashboard. + +### T08 — Antifragility Dashboard + +`AntifragilityDashboardAction` wrapped with `autoRefresh do`. Five panels: +1. **KPI row**: total deployments / avg evaluation score / % improved signals / regression count +2. **Regression alerts**: widgets currently in regression (red panel, links to widget show pages) +3. **Open gaps**: decisions with impl refs but no deployment record yet +4. **Recent deployments** (last 20): version ref, decision title, signal dots, evaluation score +5. **Recurrence leaderboard**: top 10 widgets by cycle count + +Linked from hub show page (green "Antifragility" button) and governance dashboard. + +--- + +## Known Limitations + +- **Pre/post comparison uses Haskell-side filtering**, not SQL aggregates. For production use with large datasets, replace `computeComparison` with `[typedSql|...|]` queries (IHP v1.5 typed quasiquoter). +- **Regression detection is a heuristic**. It detects any high/critical annotation after an improved signal — it does not distinguish whether the annotation relates to the same aspect of the widget that was improved. +- **Cycle count requires a strict data chain**. Cycles are only counted when the full candidate→requirement→decision→deployment chain exists. Partial cycles (e.g., a decision without a deployment) are not counted. +- **No ML in Phase 4**. All scoring and detection is rule-based. Agent-assisted distillation begins in Phase 5. + +--- + +## Phase 5 Readiness + +Phase 5 (Agent-Assisted Distillation) can now build on: +- `OutcomeSignal` as evidence input for agent distillation +- `ChangeEvaluation` scores as feedback signal for agent learning +- `RegressionDetection` results as priority signal for agent attention +- The full traceability chain from widget to outcome is traversable in one hop diff --git a/workplans/IHUB-WP-0004-ihf-phase4-outcome-observation-and-antifragility.md b/workplans/IHUB-WP-0004-ihf-phase4-outcome-observation-and-antifragility.md index 44465fb..a716631 100644 --- a/workplans/IHUB-WP-0004-ihf-phase4-outcome-observation-and-antifragility.md +++ b/workplans/IHUB-WP-0004-ihf-phase4-outcome-observation-and-antifragility.md @@ -4,7 +4,7 @@ type: workplan title: "IHF Phase 4 — Outcome Observation and Antifragility Loop" domain: inter_hub repo: inter-hub -status: active +status: done owner: custodian topic_slug: inter_hub created: "2026-03-29" @@ -69,7 +69,7 @@ Reference: `docs/ihp-overview.md`, `docs/ihp-data-and-queries.md`, ```task id: IHUB-WP-0004-T01 -status: todo +status: done priority: high state_hub_task_id: "4d0aa6d5-f291-4053-a487-8c64627f8271" ``` @@ -149,7 +149,7 @@ CREATE INDEX change_evaluations_deployment_id_idx ON change_evaluations (deploym ```task id: IHUB-WP-0004-T02 -status: todo +status: done priority: high state_hub_task_id: "4932b036-fe91-4146-9b35-7d3031894c2d" ``` @@ -175,7 +175,7 @@ full decision → requirement → widget chain. ```task id: IHUB-WP-0004-T03 -status: todo +status: done priority: high state_hub_task_id: "8b39bbb3-4129-4acc-97ac-38ecfcfd7c88" ``` @@ -202,7 +202,7 @@ append-only constraint verified; color roles applied consistently. ```task id: IHUB-WP-0004-T04 -status: todo +status: done priority: high state_hub_task_id: "27c4de52-755a-40e7-bef3-986fe4470f7c" ``` @@ -226,7 +226,7 @@ direction color-coded correctly; works with no post-deployment data (shows "—" ```task id: IHUB-WP-0004-T05 -status: todo +status: done priority: high state_hub_task_id: "844a828b-b7d7-4822-becc-b377c08c673a" ``` @@ -253,7 +253,7 @@ affected widgets; count accurate on dashboard. ```task id: IHUB-WP-0004-T06 -status: todo +status: done priority: medium state_hub_task_id: "391c6136-baea-417a-9291-5ba9f633e03f" ``` @@ -275,7 +275,7 @@ correct color in all views. ```task id: IHUB-WP-0004-T07 -status: todo +status: done priority: medium state_hub_task_id: "6a5eea23-4e73-441a-ab93-49f5b76bf3ea" ``` @@ -300,7 +300,7 @@ visible on widget show page. ```task id: IHUB-WP-0004-T08 -status: todo +status: done priority: high state_hub_task_id: "e5c65c77-c757-49a6-a8e9-99c8d3503f59" ``` @@ -326,7 +326,7 @@ All five panels render with correct data. ```task id: IHUB-WP-0004-T09 -status: todo +status: done priority: high state_hub_task_id: "1dda0a32-4913-4007-a9f4-1d86761a8cf1" ```