From 98fb1595820bc54bd09a3d28ba7b7fc7243d38a7 Mon Sep 17 00:00:00 2001 From: Bernd Worsch Date: Sun, 29 Mar 2026 21:49:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(P7):=20IHF=20Phase=207=20complete=20?= =?UTF-8?q?=E2=80=94=20advanced=20observability=20and=20operational=20inte?= =?UTF-8?q?gration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T01 schema: friction_scores, bottleneck_records, hub_health_snapshots, cross_hub_propagations + migration 1743552000. T02 Widget Pain Heatmap: computeFrictionScore (formula documented), RecomputeFriction action, colour-coded grid view (green/yellow/amber/red). T03 Workflow Bottleneck Analysis: detectBottlenecks across 4 pipeline stages (candidate 30d, requirement 60d, decision 30d, observation 14d), idempotent, severity from age ratio, resolve action. T04 Hub Health Correlation: computeHubHealth (deduction table documented), append-only HubHealthSnapshot, health history view, badge on hub Show page. T05 Cross-Hub Propagation: annotation_cluster + widget_type_friction heuristics, idempotent detection, acknowledge/resolve lifecycle. T06 Operational Review Board: 4-panel AutoRefresh global dashboard — health matrix, top-10 friction, bottleneck stage counts, open propagations. T07 gate: 5 describe blocks in Test/Integration.hs; SCOPE.md updated Phase 7 complete; docs/phase7-summary.md written. Co-Authored-By: Claude Sonnet 4.6 --- .claude/ralph-loop.local.md | 516 +++++++++--------- Application/Helper/BottleneckDetector.hs | 101 ++++ Application/Helper/CrossHubPropagation.hs | 78 +++ Application/Helper/FrictionScore.hs | 64 +++ Application/Helper/HubHealth.hs | 74 +++ ...2000-ihf-phase7-advanced-observability.sql | 64 +++ Application/Schema.sql | 68 +++ SCOPE.md | 6 +- Test/Integration.hs | 114 ++++ Web/Controller/CrossHubPropagations.hs | 37 ++ Web/Controller/Hubs.hs | 121 ++++ Web/FrontController.hs | 4 + Web/Routes.hs | 3 + Web/Types.hs | 15 + Web/View/CrossHubPropagations/Index.hs | 88 +++ Web/View/Hubs/BottleneckDashboard.hs | 97 ++++ Web/View/Hubs/FrictionHeatmap.hs | 68 +++ Web/View/Hubs/HubHealthHistory.hs | 87 +++ Web/View/Hubs/OperationalReviewBoard.hs | 179 ++++++ Web/View/Hubs/Show.hs | 12 + docs/phase7-summary.md | 88 +++ ...servability-and-operational-integration.md | 16 +- 22 files changed, 1638 insertions(+), 262 deletions(-) create mode 100644 Application/Helper/BottleneckDetector.hs create mode 100644 Application/Helper/CrossHubPropagation.hs create mode 100644 Application/Helper/FrictionScore.hs create mode 100644 Application/Helper/HubHealth.hs create mode 100644 Application/Migration/1743552000-ihf-phase7-advanced-observability.sql create mode 100644 Web/Controller/CrossHubPropagations.hs create mode 100644 Web/View/CrossHubPropagations/Index.hs create mode 100644 Web/View/Hubs/BottleneckDashboard.hs create mode 100644 Web/View/Hubs/FrictionHeatmap.hs create mode 100644 Web/View/Hubs/HubHealthHistory.hs create mode 100644 Web/View/Hubs/OperationalReviewBoard.hs create mode 100644 docs/phase7-summary.md diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md index 8087b49..4debf7f 100644 --- a/.claude/ralph-loop.local.md +++ b/.claude/ralph-loop.local.md @@ -4,14 +4,14 @@ iteration: 1 session_id: max_iterations: 20 completion_promise: "HEUREKA" -workplan_id: IHUB-WP-0006 -workplan_file: workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md -started_at: "2026-03-29T21:00:29Z" +workplan_id: IHUB-WP-0007 +workplan_file: workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md +started_at: "2026-03-29T21:41:13Z" --- ## Workplan Status Check — Do This First, Every Iteration -Read the workplan file at: `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md` +Read the workplan file at: `workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md` Count the task blocks (fenced code blocks with language tag `task`): - How many tasks exist in total? @@ -25,357 +25,371 @@ Otherwise: continue with the implementation below. --- -## Workplan: IHUB-WP-0006 — IHF Phase 6 — Cross-Framework UI Adaptation Layer -**File:** `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md` +## Workplan: IHUB-WP-0007 — IHF Phase 7 — Advanced Observability and Operational Integration +**File:** `workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md` -# IHF Phase 6 — Cross-Framework UI Adaptation Layer +# IHF Phase 7 — Advanced Observability and Operational Integration ## Goal -Ensure semantic continuity while the UI stack diversifies. Phase 5 established -AI-assisted distillation within the IHP server-rendered surface. Phase 6 ensures -that widget identity, interaction capture, and annotation capability are -preserved when UI components are authored outside of IHP HSX — React, Vue, or -any JS-based component — without bypassing the IHF core. - -All Phase 6 artifacts are formal contracts rather than free-form conventions. -A widget that participates via an adapter must honour the same identity, -traceability, and event-capture obligations as a native IHP widget. +Integrate interaction governance with broader operational intelligence. Phase 6 +established cross-framework widget participation. Phase 7 turns the accumulated +interaction data into operational intelligence: friction heatmaps, pipeline +bottleneck detection, per-hub health scores, and cross-hub pattern propagation. +The capstone is an Operational Review Board dashboard that gives hub leaders a +unified view across all hubs. ## Background -Phases 1–5 are complete. The IHF core (widget registry, interaction events, -annotations, requirements, decisions, outcomes, agent assistance) is stable. +Phases 1–6 are complete. The IHF core (widget registry, interaction events, +annotations, requirements, decisions, outcomes, agent assistance, +cross-framework adapters) is stable and extensible. -The spec (§Phase 6) calls for: -- widget protocol adapters -- metadata emission standards -- client-side SDKs or thin adapters -- cross-framework annotation launcher -- standardized interaction reporting interface +The spec (§Phase 7) calls for: +- Hub health correlation +- Policy violation correlation +- Workflow bottleneck analysis +- Interaction pain heatmaps +- Queue and job linkage +- Cross-hub issue propagation analysis -Artifacts introduced: `WidgetAdapterSpec`, `InteractionReportingContract`, -`EnvelopeEmissionContract`. +Artifacts introduced: `FrictionScore`, `BottleneckRecord`, `HubHealthSnapshot`, +`CrossHubPropagation`. -Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 6, -`docs/ihp-overview.md`, `docs/ihp-controllers-views-forms.md`. +Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 7, +`docs/phase6-summary.md`, `docs/ihp-controllers-views-forms.md`. -## Phase 6 Exit Criteria (from IHF spec §Phase 6) +## Phase 7 Exit Criteria (from IHF spec §Phase 7) -- New UI technologies can participate without bypassing the IHF core -- Widget identity remains stable across frontend evolution -- Annotations and interaction events remain compatible +- Interaction data informs operational decision-making +- Hub leaders can inspect systemic friction patterns +- The platform supports cross-domain learning -## Data Artifacts Introduced (Phase 6) +## Data Artifacts Introduced (Phase 7) -`WidgetAdapterSpec`, `InteractionReportingContract`, `EnvelopeEmissionContract` +`FrictionScore`, `BottleneckRecord`, `HubHealthSnapshot`, `CrossHubPropagation` ## Tasks -### T01 — Schema: WidgetAdapterSpec, InteractionReportingContract, EnvelopeEmissionContract +### T01 — Schema: FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation ```task -id: IHUB-WP-0006-T01 +id: IHUB-WP-0007-T01 status: todo priority: high -state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7" +state_hub_task_id: "86e31f8b-62a3-4176-9b10-2fe7a8dbcc23" ``` -Add Phase 6 tables to `Application/Schema.sql` and write migration: +Add Phase 7 tables to `Application/Schema.sql` and write migration: ```sql --- Describes how a specific UI technology (React, Vue, etc.) maps to IHF widget --- protocol obligations — identity, envelope emission, event reporting. -CREATE TABLE widget_adapter_specs ( +-- Aggregated pain score per widget, recomputed on demand or scheduled. +CREATE TABLE friction_scores ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, - name TEXT NOT NULL UNIQUE, -- e.g. "react-18", "vue-3", "web-component" - framework TEXT NOT NULL, -- e.g. "react", "vue", "vanilla" - version TEXT NOT NULL, -- adapter spec version, e.g. "1.0" - envelope_contract_id UUID REFERENCES envelope_emission_contracts(id), - reporting_contract_id UUID REFERENCES interaction_reporting_contracts(id), - status TEXT NOT NULL DEFAULT 'draft', - -- status values: draft | active | deprecated + widget_id UUID NOT NULL REFERENCES widgets(id), + score INTEGER NOT NULL DEFAULT 0, + -- 0–100; higher = more friction + annotation_count INTEGER NOT NULL DEFAULT 0, + error_event_count INTEGER NOT NULL DEFAULT 0, + regression_flag BOOLEAN NOT NULL DEFAULT FALSE, + stale_candidate_count INTEGER NOT NULL DEFAULT 0, + last_computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + UNIQUE (widget_id) +); + +CREATE INDEX friction_scores_widget_id_idx ON friction_scores (widget_id); +CREATE INDEX friction_scores_score_idx ON friction_scores (score DESC); + +-- Detected stalls at specific pipeline stages. +CREATE TABLE bottleneck_records ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + hub_id UUID NOT NULL REFERENCES hubs(id), + stage TEXT NOT NULL, + -- 'candidate' | 'requirement' | 'decision' | 'observation' + subject_type TEXT NOT NULL, + -- 'RequirementCandidate' | 'Requirement' | 'DecisionRecord' | 'DeploymentRecord' + subject_id UUID NOT NULL, + stalled_since TIMESTAMP WITH TIME ZONE NOT NULL, + severity TEXT NOT NULL DEFAULT 'medium', + -- 'low' | 'medium' | 'high' | 'critical' + resolved_at TIMESTAMP WITH TIME ZONE, notes TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL -); - -CREATE INDEX widget_adapter_specs_framework_idx ON widget_adapter_specs (framework); -CREATE INDEX widget_adapter_specs_status_idx ON widget_adapter_specs (status); - --- Formalises the rules for how a widget envelope must be emitted: --- which attributes are required, their format, and version. -CREATE TABLE envelope_emission_contracts ( - id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, - contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0", "1.1" - required_attributes JSONB NOT NULL, - -- e.g. ["data-widget-id", "data-view-context", "data-hub-id"] - optional_attributes JSONB NOT NULL DEFAULT '[]', - validation_rules JSONB NOT NULL DEFAULT '{}', - -- machine-readable rules: format checks, presence guards - description TEXT, - status TEXT NOT NULL DEFAULT 'active', - -- status values: draft | active | superseded created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL ); --- Standardised REST interface contract for external event and annotation --- submission — used by non-IHP adapters. -CREATE TABLE interaction_reporting_contracts ( +CREATE INDEX bottleneck_records_hub_id_idx ON bottleneck_records (hub_id); +CREATE INDEX bottleneck_records_stage_idx ON bottleneck_records (stage); +CREATE INDEX bottleneck_records_resolved_idx ON bottleneck_records (resolved_at) + WHERE resolved_at IS NULL; + +-- Periodic health snapshots for trend tracking. +CREATE TABLE hub_health_snapshots ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, - contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0" - endpoint_path TEXT NOT NULL, -- e.g. "/api/v1/interaction-events" - accepted_event_types JSONB NOT NULL, -- e.g. ["clicked","viewed","submitted"] - required_fields JSONB NOT NULL, - -- minimum payload: widget_id, hub_id, event_type, occurred_at - auth_scheme TEXT NOT NULL DEFAULT 'bearer', - description TEXT, - status TEXT NOT NULL DEFAULT 'active', - created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL + hub_id UUID NOT NULL REFERENCES hubs(id), + health_score INTEGER NOT NULL, + -- 0–100 + open_candidates INTEGER NOT NULL DEFAULT 0, + regressed_widgets INTEGER NOT NULL DEFAULT 0, + stale_decisions INTEGER NOT NULL DEFAULT 0, + active_bottlenecks INTEGER NOT NULL DEFAULT 0, + computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL ); --- Link widgets to their adapter spec (null = native IHP widget). -ALTER TABLE widgets - ADD COLUMN adapter_spec_id UUID REFERENCES widget_adapter_specs(id); +CREATE INDEX hub_health_snapshots_hub_id_idx ON hub_health_snapshots (hub_id); +CREATE INDEX hub_health_snapshots_computed_at_idx + ON hub_health_snapshots (hub_id, computed_at DESC); -CREATE INDEX widgets_adapter_spec_id_idx ON widgets (adapter_spec_id); +-- Patterns detected across multiple hubs. +CREATE TABLE cross_hub_propagations ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + pattern_type TEXT NOT NULL, + -- 'annotation_cluster' | 'widget_type_friction' + source_hub_id UUID REFERENCES hubs(id), + affected_hub_ids JSONB NOT NULL DEFAULT '[]', + -- array of hub UUIDs + summary TEXT NOT NULL, + detected_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + -- 'open' | 'acknowledged' | 'resolved' + notes TEXT +); + +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); ``` -**Exit criteria:** `migrate` runs cleanly; all Phase 6 types available in GHCi. +**Exit criteria:** `migrate` runs cleanly; all Phase 7 types available in GHCi. -### T02 — EnvelopeEmissionContract: formalise widgetEnvelope as a versioned contract +### T02 — Widget Pain Heatmap: friction scoring and per-hub heatmap view ```task -id: IHUB-WP-0006-T02 +id: IHUB-WP-0007-T02 status: todo priority: high -state_hub_task_id: "298af675-550b-480b-bed6-05efc79cd0c9" +state_hub_task_id: "3a5ecd28-17c2-4258-bfd9-b3eaecf52135" ``` -1. Seed the canonical v1.0 `EnvelopeEmissionContract` record in a migration: - - `required_attributes: ["data-widget-id", "data-view-context", "data-hub-id"]` - - `optional_attributes: ["data-policy-scope", "data-widget-version"]` - - `validation_rules: {data-widget-id: "uuid", data-hub-id: "uuid"}` -2. Update the `widgetEnvelope` helper (`Web/View/Helpers.hs` or equivalent) to - read the active contract version from DB (or config) and assert required - attributes at render time — log a warning (not crash) if any are missing. -3. Add `EnvelopeEmissionContractsController`: - - `index`: table of contract versions with status badges - - `show`: full required/optional attributes and validation rules as formatted - JSON panels - - Read-only (contracts are immutable once active; a new version supersedes) -4. Link from global nav under "Contracts" +1. Add `Application/Helper/FrictionScore.hs` with `computeFrictionScore`: + - `annotation_count` — total annotations for widget + - `error_event_count` — events with `event_type = 'errored'` + - `regression_flag` — `True` if widget appears in `regressedWidgetIds` + - `stale_candidate_count` — open candidates older than 30 days + - Score formula (documented in module header): + ``` + score = min 100 $ + annotationCount * 5 + + errorEventCount * 10 + + (if regressionFlag then 20 else 0) + + staleCandidateCount * 8 + ``` + - Upserts into `friction_scores` (UPDATE if exists, INSERT otherwise) +2. Add `RecomputeFrictionAction { hubId }` to `HubsController`: + - Recomputes scores for all widgets in the hub + - Redirects back to heatmap view +3. Add `FrictionHeatmapAction { hubId }` view: + - Grid of widget cards, colour-coded by score band: + - 0–19: green (`bg-green-100`) + - 20–39: yellow (`bg-yellow-100`) + - 40–59: amber (`bg-orange-100`) + - 60+: red (`bg-red-100`) + - Each card: widget name, score, link to widget show + - "Recompute" button triggers `RecomputeFrictionAction` +4. Link from hub Show page as "Friction Heatmap" -**Exit criteria:** Active contract record exists in DB; widgetEnvelope validates -against it; contract index/show pages render correctly. +**Exit criteria:** Scores compute correctly for test fixtures; heatmap renders +with correct colour bands; recompute updates scores. -### T03 — InteractionReportingContract: REST endpoint for external event submission +### T03 — Workflow Bottleneck Analysis: stall detection across the pipeline ```task -id: IHUB-WP-0006-T03 +id: IHUB-WP-0007-T03 status: todo priority: high -state_hub_task_id: "f2767465-ff00-48be-b2dc-5bf3b179cca9" +state_hub_task_id: "ada0347a-880b-454e-843f-4a9135ea8739" ``` -1. Seed the canonical v1.0 `InteractionReportingContract`: - - `endpoint_path: "/api/v1/interaction-events"` - - `accepted_event_types: ["clicked","viewed","submitted","dismissed","errored"]` - - `required_fields: ["widget_id","hub_id","event_type","occurred_at"]` -2. Add `Api.InteractionEventsController` (separate from the web controller): - - `POST /api/v1/interaction-events` — JSON body, Bearer token auth - - Validate payload against the active `InteractionReportingContract` - - Create `InteractionEvent` record - - Return `201 Created` with `{id, widget_id, event_type}` or `422` with - validation errors -3. Register the API route in `FrontController.hs` -4. Add `InteractionReportingContractsController` (read-only, same pattern as T02) +1. Add `Application/Helper/BottleneckDetector.hs` with `detectBottlenecks`: + - Stage 1 — `candidate`: `RequirementCandidate` with `status='open'` and + `created_at < now() - interval '30 days'` + - Stage 2 — `requirement`: `Requirement` with no linked `DecisionRecord` and + `created_at < now() - interval '60 days'` + - Stage 3 — `decision`: `DecisionRecord` with no linked `DeploymentRecord` + and `decided_at < now() - interval '30 days'` + - Stage 4 — `observation`: `DeploymentRecord` with no linked `OutcomeSignal` + and `deployed_at < now() - interval '14 days'` + - Severity: `critical` if age > 2× threshold, else `high` if > 1.5×, else `medium` + - Upserts `BottleneckRecord` (skip if already exists for same subject) +2. Add `DetectBottlenecksAction { hubId }` — runs detector, redirects to dashboard +3. Add `BottleneckDashboardAction { hubId }` view: + - Table grouped by pipeline stage + - Columns: subject (linked), stalled since, age, severity badge + - "Resolve" button → `ResolveBottleneckAction { bottleneckRecordId }` + - "Detect" button triggers fresh detection +4. Link from hub Show page as "Bottlenecks" -**Exit criteria:** `POST /api/v1/interaction-events` with a valid payload creates -an `InteractionEvent`; invalid payloads return `422`; contract show page renders. +**Exit criteria:** Stale candidates create bottleneck records; dashboard renders +and groups correctly; resolve marks `resolved_at`. -### T04 — WidgetAdapterSpecsController and registry dashboard +### T04 — Hub Health Correlation: composite health score and history ```task -id: IHUB-WP-0006-T04 +id: IHUB-WP-0007-T04 status: todo priority: high -state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db" +state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6" ``` -1. Scaffold `WidgetAdapterSpecsController`: - - `index`: table of adapters — framework badge, version, status, envelope - contract version, reporting contract version - - `new` / `create`: register a new adapter spec - - `show`: full detail — framework, version, linked contracts, notes, status - - `edit` / `update`: update notes and status only (contracts are immutable - once linked) - - No delete — adapter specs are audit artifacts -2. Validation: - - `name`, `framework`, `version` required - - `status` must be `draft | active | deprecated` -3. On widget `new`/`edit` forms: optional `adapter_spec_id` select (null = native) -4. On widget show page: if `adapter_spec_id` present, show adapter badge with - link to the spec +1. Add `Application/Helper/HubHealth.hs` with `computeHubHealth`: + - Deduction table (documented in module): + ``` + -5 per open RequirementCandidate + -10 per regressed widget + -8 per stale DecisionRecord (decided > 30 days, no deployment) + -12 per active critical BottleneckRecord + -6 per active high BottleneckRecord + floor at 0 + ``` + - Inserts new `HubHealthSnapshot` (never updates — history is append-only) +2. Add `SnapshotHubHealthAction { hubId }` — computes and redirects to history +3. Add `HubHealthHistoryAction { hubId }` view: + - Table of snapshots: timestamp, score (colour-coded), component breakdown + - Latest score shown prominently at top +4. Show health score badge on hub Show page (next to dashboard links): + - Fetch latest snapshot; display colour-coded score pill + - If no snapshot: "–" with link to take first snapshot -**Exit criteria:** Adapter specs can be registered, listed, and viewed; widget -form allows adapter assignment; widget show page renders adapter badge. +**Exit criteria:** Snapshot computes correct score against test fixtures; history +table renders in order; badge appears on hub Show page. -### T05 — Cross-framework annotation launcher (lightweight JS widget) +### T05 — Cross-Hub Propagation Analysis: pattern detection across hubs ```task -id: IHUB-WP-0006-T05 +id: IHUB-WP-0007-T05 status: todo priority: medium -state_hub_task_id: "fea86955-d5e6-4623-b5cc-f422c266c9cf" +state_hub_task_id: "7a860b9f-a835-47d6-96d8-2964ae37b12d" ``` -1. Create `static/js/ihf-annotation-launcher.js` — a self-contained vanilla JS - module (no framework dependency): - - On `DOMContentLoaded`, scan for elements with `data-widget-id` attribute - - Inject a small "annotate" trigger (button or icon) adjacent to each - enrolled element - - On trigger click: open a lightweight inline form (textarea + category - select) and POST to `/annotations` (existing IHP endpoint) via `fetch` - - On success: show a brief confirmation; on error: show inline error message - - Reads `data-hub-id` from the element (or nearest ancestor) for the hub - context -2. The launcher must work in React-rendered pages where IHP does not own the - DOM — it relies solely on `data-widget-id` presence. -3. Include as an optional script tag in the IHP layout (`Web/View/Layout.hs`) - with a feature flag (`IHP_ANNOTATION_LAUNCHER=true`) -4. Document usage in `docs/annotation-launcher.md` +1. Add `Application/Helper/CrossHubPropagation.hs` with `detectPropagations`: + - **Annotation cluster heuristic**: for each annotation `category`, count + distinct hubs with ≥3 annotations in that category in the last 14 days. + If ≥2 hubs qualify, emit a `CrossHubPropagation` with + `pattern_type='annotation_cluster'` and a generated summary. + - **Widget type friction heuristic**: for each `widget_type`, count hubs + where the max `FrictionScore` for that type is ≥40. If ≥2 hubs qualify, + emit `pattern_type='widget_type_friction'`. + - Skip if a matching open/acknowledged propagation already exists + (idempotent detection) +2. Add `DetectPropagationsAction` (global, no hubId) — runs detector +3. Add `CrossHubPropagationsAction` view (global): + - Table: pattern type, source hub, affected hubs (comma list), summary, + detected at, status badge + - "Acknowledge" and "Resolve" actions +4. Link from global nav (alongside "Adapters", "Ops Review") -**Exit criteria:** Launcher script injects annotation triggers on a page with -`data-widget-id` elements; annotation POST succeeds; works from a static HTML -test page (not IHP-rendered). +**Exit criteria:** Detection creates propagation records for qualifying patterns; +duplicate runs are idempotent; acknowledge/resolve transitions work. -### T06 — React adapter specification and reference example +### T06 — Operational Review Board Dashboard: cross-hub unified view ```task -id: IHUB-WP-0006-T06 +id: IHUB-WP-0007-T06 status: todo priority: medium -state_hub_task_id: "023269d8-9835-40b4-a394-478a0f36eee0" +state_hub_task_id: "ffabc4d1-c166-4b7d-8bec-55365cbe0666" ``` -1. Register a `react-18` `WidgetAdapterSpec` record (via migration seed or - admin UI): - - links to envelope v1.0 contract and reporting v1.0 contract - - `status = active` -2. Create `static/js/ihf-react-adapter.js` — a thin React hook + HOC: - - `useWidgetEnvelope(widgetId, hubId, viewContext)` — returns a `ref` and - `data-*` props object conforming to the envelope contract - - `withWidgetEnvelope(WrappedComponent, widgetId, hubId, viewContext)` — HOC - that applies the envelope to the root DOM element - - `useInteractionReporter(widgetId, hubId)` — returns a `reportEvent(type)` - function that POSTs to `/api/v1/interaction-events` -3. Create `docs/react-adapter.md` with usage examples for all three exports -4. Add a test fixture page in `static/` demonstrating a React widget using the - adapter alongside an IHP-rendered widget on the same page +1. Add `OperationalReviewBoardAction` to a new `OperationsController` + (or `HubsController` as a global action — no `hubId` parameter): + - **Panel 1 — Hub health matrix**: all hubs, latest health score (or "–"), + colour-coded row, link to hub and to health history + - **Panel 2 — Top friction widgets**: top 10 across all hubs by + `FrictionScore.score DESC`; columns: widget name, hub, score band, link + - **Panel 3 — Active bottlenecks by stage**: count of unresolved bottlenecks + per stage across all hubs; click-through to hub bottleneck dashboard + - **Panel 4 — Open cross-hub propagations**: list of open/acknowledged + propagation events with pattern type and affected hub count +2. `autoRefresh` — live-updates +3. Link from global nav as "Ops Review" +4. Link from global nav cross-hub propagation count badge if > 0 -**Exit criteria:** `useWidgetEnvelope` emits correct `data-*` attributes; -`reportEvent` reaches `/api/v1/interaction-events`; annotation launcher script -picks up the React widget's `data-widget-id`; docs written. +**Exit criteria:** Dashboard renders all four panels; health matrix shows all +hubs; top friction list is correctly sorted; live-updates on data change. -### T07 — Adapter compatibility validation dashboard +### T07 — Phase 7 gate: tests, consistency, docs ```task -id: IHUB-WP-0006-T07 -status: todo -priority: medium -state_hub_task_id: "dc8fa48a-7195-4410-a77e-717b53127c2e" -``` - -1. Add `AdapterCompatibilityDashboardAction { hubId }` to `HubsController` - (AutoRefresh): - - **Adapter summary**: count of registered adapters by status - (draft / active / deprecated) - - **Widget coverage**: total widgets / native IHP / adapter-backed (per - adapter spec), with percentage bars - - **Contract versions in use**: which envelope and reporting contract - versions are active - - **Unassigned widgets**: widgets with no `adapter_spec_id` that have - received events from external origins (heuristic: `user_agent` not - matching IHP server) - - **Stale adapters**: adapter specs with `status=active` but no widgets - assigned in the last 30 days -2. Link from hub Show page alongside Triage / Governance / Antifragility / - Agent dashboards -3. Add "Adapters" link to global nav - -**Exit criteria:** Dashboard renders all five panels; live-updates on widget or -adapter changes; stale adapter detection works. - - -### T08 — Phase 6 gate: tests, consistency, docs - -```task -id: IHUB-WP-0006-T08 +id: IHUB-WP-0007-T07 status: todo priority: high -state_hub_task_id: "90ea4814-7603-4016-be34-d41ae091f7e1" +state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05" ``` 1. **Integration tests** (`Test/`): - - EnvelopeEmissionContract create + fetch (required_attributes, validation_rules) - - InteractionReportingContract create + fetch - - `POST /api/v1/interaction-events` — valid payload creates InteractionEvent - - `POST /api/v1/interaction-events` — missing required field returns 422 - - WidgetAdapterSpec create + status transition (draft → active → deprecated) - - Widget with adapter_spec_id: fetch + show renders adapter badge - - Adapter compatibility dashboard: compiles and returns correct widget counts + - `FrictionScore` compute formula: widget with known annotation count → + expected score + - `BottleneckRecord` create + resolve: stale candidate → bottleneck detected; + resolve sets `resolved_at` + - `HubHealthSnapshot` compute: hub with known candidates/regressions → expected + score; history fetch returns in order + - `CrossHubPropagation` create + acknowledge + resolve + - `OperationalReviewBoard` action: compiles, fetches all hubs, returns counts 2. **Consistency sync** via State Hub MCP: `check_repo_consistency(repo_slug="inter-hub", fix=True)` 3. **Documentation updates:** - - Update `SCOPE.md` current state section: Phase 6 complete - - Write `docs/phase6-summary.md`: what was built, contract model, adapter - pattern, known limitations, Phase 7 readiness + - Update `SCOPE.md` current state section: Phase 7 complete + - Write `docs/phase7-summary.md`: what was built, scoring formulae, bottleneck + thresholds, cross-hub heuristics, known limitations, Phase 8 readiness 4. **Smoke test checklist:** - - Register a `react-18` adapter spec via UI - - Assign a widget to the adapter - - POST a test interaction event via `curl` to `/api/v1/interaction-events` - - Verify event appears in widget show page - - Open annotation launcher on a page with a React-backed widget - - Confirm adapter compatibility dashboard shows correct coverage + - Create two hubs with widgets and annotations; run friction recompute; verify + heatmap colours + - Age a candidate by force-setting `created_at`; run detect bottlenecks; + verify record appears + - Snapshot health for both hubs; verify Ops Review Board health matrix + - Trigger cross-hub propagation detection; verify propagation record + - Open Ops Review Board; confirm all four panels populate **Exit criteria:** All tests pass; consistency sync reports no errors; smoke test completed; SCOPE.md updated. -## Phase 6 Dependencies +## Phase 7 Dependencies -- Phases 1–5 schema stable (widget registry, interaction events, and annotation - model required for adapter integration) -- `envelope_emission_contracts` and `interaction_reporting_contracts` must exist - before `widget_adapter_specs` (foreign key; T01 handles both in one migration) -- Contracts (T01–T03) before adapter spec controller (T04) -- Adapter spec controller (T04) before annotation launcher (T05) and React - adapter (T06) — widget assignment UI depends on T04 -- All feature tasks (T01–T07) before gate (T08) +- Phases 1–6 schema stable (widget registry, interaction events, annotations, + requirements, decisions, outcomes, agent proposals, adapter specs) +- `friction_scores` requires widgets (T01 before T02) +- `bottleneck_records` requires hubs, candidates, requirements, decisions, + deployments (T01 before T03) +- `hub_health_snapshots` requires hubs and reads from bottleneck_records + (T03 before T04) +- `cross_hub_propagations` requires hub friction scores (T02 before T05) +- Operational Review Board aggregates all Phase 7 data (T02–T05 before T06) +- All feature tasks (T01–T06) before gate (T07) ## Notes -- **Contracts are immutable once active.** A new version supersedes the old; - old versions remain readable for audit. No in-place edits after status=active. -- **Native IHP widgets are unaffected.** `adapter_spec_id` is nullable. Existing - widgets continue to function exactly as before. -- **The JS adapter is a thin client.** It does not embed a framework build - pipeline. `ihf-react-adapter.js` is a plain ESM module; consumers bundle it - themselves. -- **Auth for the reporting API.** Bearer token scheme. In Phase 6 the token - is a per-hub API key stored in `hubs.api_key` (add column in T01 migration). - Phase 8 (federated) can layer on OAuth. -- **No local JS build toolchain added.** Static JS files are served as-is. - Phase 6 does not introduce npm, webpack, or esbuild into the IHP project. +- **Friction scores are recomputed, not append-only.** Each widget has at most + one `FrictionScore` row (unique constraint on `widget_id`). Historical trend + is not tracked at the friction level — use `HubHealthSnapshot` for trends. +- **Bottleneck detection is idempotent.** Re-running the detector skips records + where an unresolved bottleneck already exists for the same subject. +- **Health snapshots are append-only.** Every `SnapshotHubHealthAction` call + inserts a new row. This preserves the health history for trend analysis. +- **Cross-hub detection requires FrictionScores to be current.** Run + `RecomputeFrictionAction` for all hubs before `DetectPropagationsAction`. +- **No scheduled jobs in Phase 7.** Detection and recomputation are triggered + manually via UI or curl. Phase 8 can layer on a cron/job system. +- **Severity thresholds and score weights are constants in the helper modules.** + They are intentionally not stored in the DB to avoid config drift — change + them in code and recompute. --- @@ -408,7 +422,7 @@ status: active → status: done Before marking the workplan done and outputting `HEUREKA`, verify all of the following are true: -1. Every task block in `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md` has `status: done` +1. Every task block in `workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md` has `status: done` 2. The workplan frontmatter `status` is `done` 3. The full test suite passes with no failures 4. The codebase passes the project's standard code-quality checks diff --git a/Application/Helper/BottleneckDetector.hs b/Application/Helper/BottleneckDetector.hs new file mode 100644 index 0000000..c11201e --- /dev/null +++ b/Application/Helper/BottleneckDetector.hs @@ -0,0 +1,101 @@ +module Application.Helper.BottleneckDetector where + +import IHP.Prelude +import IHP.ModelSupport +import Generated.Types +import Data.Time.Clock (addUTCTime, getCurrentTime, NominalDiffTime) + +-- | Severity based on how much older than the threshold the record is. +staleSeverity :: NominalDiffTime -> NominalDiffTime -> Text +staleSeverity age threshold + | age > threshold * 2 = "critical" + | age > threshold * 1.5 = "high" + | otherwise = "medium" + +-- | Detect pipeline bottlenecks for a hub and upsert BottleneckRecord rows. +-- Idempotent: skips subjects that already have an unresolved record. +detectBottlenecks + :: (?modelContext :: ModelContext) + => Id Hub + -> [Widget] + -> [RequirementCandidate] + -> [Requirement] + -> [DecisionRecord] + -> [DeploymentRecord] + -> IO [BottleneckRecord] +detectBottlenecks hubId hubWidgets candidates requirements decisions deployments = do + now <- getCurrentTime + existing <- query @BottleneckRecord + |> filterWhere (#hubId, hubId) + |> filterWhereSql (#resolvedAt, "IS NULL") + |> fetch + let existingSubjects = map (.subjectId) existing + + let candidateThreshold = 30 * 86400 :: NominalDiffTime + requirementThreshold = 60 * 86400 :: NominalDiffTime + decisionThreshold = 30 * 86400 :: NominalDiffTime + observationThreshold = 14 * 86400 :: NominalDiffTime + + -- Stage 1: open candidates older than 30 days + let staleCandidates = + [ (c, addUTCTime (negate candidateThreshold) now) + | c <- candidates + , c.status == "open" + , c.createdAt < addUTCTime (negate candidateThreshold) now + , c.id `notElem` map coerce existingSubjects + ] + + -- Stage 2: requirements with no decision older than 60 days + let linkedReqIds = mapMaybe (.requirementId) decisions + stalRequirements = + [ (r, addUTCTime (negate requirementThreshold) now) + | r <- requirements + , r.createdAt < addUTCTime (negate requirementThreshold) now + , r.id `notElem` linkedReqIds + , r.id `notElem` map coerce existingSubjects + ] + + -- Stage 3: decisions with no deployment older than 30 days + let linkedDecisionIds = map (.decisionId) deployments + staleDecisions = + [ (d, addUTCTime (negate decisionThreshold) now) + | d <- decisions + , d.decidedAt < addUTCTime (negate decisionThreshold) now + , d.id `notElem` linkedDecisionIds + , d.id `notElem` map coerce existingSubjects + ] + + -- Stage 4: deployments with no outcome signal older than 14 days + signalWidgetIds <- sqlQuery + "SELECT DISTINCT widget_id FROM outcome_signals" () + let signalWids = map (\(Only wid) -> wid) (signalWidgetIds :: [Only (Id Widget)]) + let widgetIdSet = map (.id) hubWidgets + let staleDeployments = + [ (dep, addUTCTime (negate observationThreshold) now) + | dep <- deployments + , dep.deployedAt < addUTCTime (negate observationThreshold) now + , not (any (\wid -> wid `elem` signalWids) widgetIdSet) + , dep.id `notElem` map coerce existingSubjects + ] + + let mkBottleneck stage subjType subjId stalledSince threshold = do + let age = now `diffUTCTime` stalledSince + severity = staleSeverity age threshold + newRecord @BottleneckRecord + |> set #hubId hubId + |> set #stage stage + |> set #subjectType subjType + |> set #subjectId (coerce subjId) + |> set #stalledSince stalledSince + |> set #severity severity + |> createRecord + + r1 <- mapM (\(c, t) -> mkBottleneck "candidate" "RequirementCandidate" c.id t candidateThreshold) staleCandidates + r2 <- mapM (\(r, t) -> mkBottleneck "requirement" "Requirement" r.id t requirementThreshold) stalRequirements + r3 <- mapM (\(d, t) -> mkBottleneck "decision" "DecisionRecord" d.id t decisionThreshold) staleDecisions + r4 <- mapM (\(d, t) -> mkBottleneck "observation" "DeploymentRecord" d.id t observationThreshold) staleDeployments + + pure (r1 <> r2 <> r3 <> r4) + +diffUTCTime :: UTCTime -> UTCTime -> NominalDiffTime +diffUTCTime a b = realToFrac (a `Data.Time.Clock.diffUTCTime` b) diff --git a/Application/Helper/CrossHubPropagation.hs b/Application/Helper/CrossHubPropagation.hs new file mode 100644 index 0000000..6aff9cc --- /dev/null +++ b/Application/Helper/CrossHubPropagation.hs @@ -0,0 +1,78 @@ +module Application.Helper.CrossHubPropagation where + +import IHP.Prelude +import IHP.ModelSupport +import Generated.Types +import Data.Time.Clock (addUTCTime, getCurrentTime) +import Data.Aeson (toJSON) +import qualified Data.List as List + +-- | Detect cross-hub propagation patterns and insert CrossHubPropagation rows. +-- Idempotent: skips patterns for which an open/acknowledged record already exists. +detectPropagations + :: (?modelContext :: ModelContext) + => [Hub] + -> [Annotation] -- all annotations across all hubs, widget already resolved + -> [Widget] -- all widgets (to map widgetId → hubId) + -> [FrictionScore] -- all friction scores + -> IO [CrossHubPropagation] +detectPropagations hubs annotations widgets frictionScores = do + now <- getCurrentTime + let fourteenDaysAgo = addUTCTime (negate $ 14 * 86400) now + + existing <- query @CrossHubPropagation + |> filterWhereSql (#status, "IN ('open','acknowledged')") + |> fetch + + -- Helper: find hub for a widget + let widgetHub wid = (.hubId) <$> find (\w -> w.id == wid) widgets + + -- Heuristic 1: annotation category clustering + -- For each category, count distinct hubs with ≥3 annotations in last 14 days + let recentAnnotations = filter (\a -> a.createdAt >= fourteenDaysAgo) annotations + categories = List.nub (map (.category) recentAnnotations) + clusterPropagations = do + cat <- categories + let catAnnots = filter (\a -> a.category == cat) recentAnnotations + hubCounts = map (\hid -> (hid, length (filter (\a -> widgetHub a.widgetId == Just hid) catAnnots))) + (List.nub (mapMaybe (\a -> widgetHub a.widgetId) catAnnots)) + qualHubs = [ hid | (hid, cnt) <- hubCounts, cnt >= 3 ] + guard (length qualHubs >= 2) + let srcHub = head qualHubs + summary = "Annotation category '" <> cat <> "' concentrated in " + <> show (length qualHubs) <> " hubs" + -- Skip if open/acknowledged record already exists with same summary + guard (not (any (\p -> p.patternType == "annotation_cluster" && p.summary == summary) existing)) + pure (srcHub, qualHubs, "annotation_cluster", summary) + + -- Heuristic 2: widget type friction across hubs + let widgetTypes = List.nub (map (.widgetType) widgets) + frictionThreshold = 40 :: Int + frictionPropagations = do + wtype <- widgetTypes + let typeWidgets = filter (\w -> w.widgetType == wtype) widgets + hubsWithHighFriction = + List.nub + [ w.hubId + | w <- typeWidgets + , Just fs <- [find (\f -> f.widgetId == w.id) frictionScores] + , fs.score >= frictionThreshold + ] + guard (length hubsWithHighFriction >= 2) + let srcHub = head hubsWithHighFriction + summary = "Widget type '" <> wtype <> "' has high friction in " + <> show (length hubsWithHighFriction) <> " hubs" + guard (not (any (\p -> p.patternType == "widget_type_friction" && p.summary == summary) existing)) + pure (srcHub, hubsWithHighFriction, "widget_type_friction", summary) + + let allPatterns = clusterPropagations <> frictionPropagations + + mapM (\(srcHubId, affectedHubIds, ptype, summary) -> + newRecord @CrossHubPropagation + |> set #patternType ptype + |> set #sourceHubId (Just srcHubId) + |> set #affectedHubIds (toJSON (map show affectedHubIds)) + |> set #summary summary + |> set #status "open" + |> createRecord + ) allPatterns diff --git a/Application/Helper/FrictionScore.hs b/Application/Helper/FrictionScore.hs new file mode 100644 index 0000000..1c82f62 --- /dev/null +++ b/Application/Helper/FrictionScore.hs @@ -0,0 +1,64 @@ +module Application.Helper.FrictionScore where + +import IHP.Prelude +import IHP.ModelSupport +import Generated.Types +import Data.Time.Clock (addUTCTime, getCurrentTime) + +-- | Friction score formula (documented): +-- +-- score = min 100 $ +-- annotationCount * 5 +-- + errorEventCount * 10 +-- + (if regressionFlag then 20 else 0) +-- + staleCandidateCount * 8 +-- +-- Inputs are computed from the widget's related records. +computeFrictionScore + :: (?modelContext :: ModelContext) + => Id Widget + -> [Annotation] -- all annotations for this widget + -> [InteractionEvent] -- all events for this widget + -> Bool -- True if widget is in regression + -> [RequirementCandidate] -- all candidates for this widget + -> IO FrictionScore +computeFrictionScore wid annotations events isRegressed candidates = do + now <- getCurrentTime + let thirtyDaysAgo = addUTCTime (negate $ 30 * 86400) now + annCount = length annotations + errCount = length (filter (\e -> e.eventType == "errored") events) + staleCount = length (filter (\c -> c.status == "open" && c.createdAt < thirtyDaysAgo) candidates) + rawScore = annCount * 5 + errCount * 10 + (if isRegressed then 20 else 0) + staleCount * 8 + finalScore = min 100 rawScore + -- Upsert: update if row exists, insert otherwise + existingRows <- sqlQuery + "SELECT * FROM friction_scores WHERE widget_id = ? LIMIT 1" + (Only wid) + case (existingRows :: [FrictionScore]) of + (existing : _) -> do + existing + |> set #score finalScore + |> set #annotationCount annCount + |> set #errorEventCount errCount + |> set #regressionFlag isRegressed + |> set #staleCandidateCount staleCount + |> set #lastComputedAt now + |> updateRecord + [] -> do + newRecord @FrictionScore + |> set #widgetId wid + |> set #score finalScore + |> set #annotationCount annCount + |> set #errorEventCount errCount + |> set #regressionFlag isRegressed + |> set #staleCandidateCount staleCount + |> set #lastComputedAt now + |> createRecord + +-- | Score band for Tailwind colour coding. +scoreBand :: Int -> Text +scoreBand s + | s < 20 = "bg-green-100 text-green-800" + | s < 40 = "bg-yellow-100 text-yellow-800" + | s < 60 = "bg-orange-100 text-orange-800" + | otherwise = "bg-red-100 text-red-800" diff --git a/Application/Helper/HubHealth.hs b/Application/Helper/HubHealth.hs new file mode 100644 index 0000000..2e8a836 --- /dev/null +++ b/Application/Helper/HubHealth.hs @@ -0,0 +1,74 @@ +module Application.Helper.HubHealth where + +import IHP.Prelude +import IHP.ModelSupport +import Generated.Types +import Data.Time.Clock (addUTCTime, getCurrentTime) + +-- | Health score deduction table (documented): +-- +-- -5 per open RequirementCandidate +-- -10 per regressed widget +-- -8 per stale DecisionRecord (decided > 30 days, no deployment) +-- -12 per active critical BottleneckRecord +-- -6 per active high BottleneckRecord +-- floor at 0, ceiling at 100 +-- +computeHubHealth + :: (?modelContext :: ModelContext) + => Id Hub + -> [Widget] + -> [RequirementCandidate] + -> [DecisionRecord] + -> [DeploymentRecord] + -> [OutcomeSignal] + -> [Annotation] + -> [BottleneckRecord] + -> IO HubHealthSnapshot +computeHubHealth hubId widgets candidates decisions deployments signals annotations bottlenecks = do + now <- getCurrentTime + let thirtyDaysAgo = addUTCTime (negate $ 30 * 86400) now + + openCandidates = filter (\c -> c.status == "open") candidates + regressedWids = regressedWidgetIds signals annotations + linkedDecIds = map (.decisionId) deployments + staleDecisions' = filter (\d -> d.decidedAt < thirtyDaysAgo && d.id `notElem` linkedDecIds) decisions + activeBN = filter (\b -> isNothing b.resolvedAt) bottlenecks + criticalBN = filter (\b -> b.severity == "critical") activeBN + highBN = filter (\b -> b.severity == "high") activeBN + + openCount = length openCandidates + regCount = length regressedWids + staleDecCount = length staleDecisions' + activeBNCount = length activeBN + + deductions = openCount * 5 + + regCount * 10 + + staleDecCount * 8 + + length criticalBN * 12 + + length highBN * 6 + score = max 0 (100 - deductions) + + newRecord @HubHealthSnapshot + |> set #hubId hubId + |> set #healthScore score + |> set #openCandidates openCount + |> set #regressedWidgets regCount + |> set #staleDecisions staleDecCount + |> set #activeBottlenecks activeBNCount + |> createRecord + +-- | Re-export from Application.Helper.Controller to avoid circular imports. +regressedWidgetIds :: [OutcomeSignal] -> [Annotation] -> [Id Widget] +regressedWidgetIds signals annotations = + let negSignalWids = [ s.widgetId | s <- signals, s.signalType == "negative" ] + negAnnotWids = [ a.widgetId | a <- annotations, a.category == "regression" ] + in nub (negSignalWids <> negAnnotWids) + +-- | Colour class for health score badge. +healthScoreBadge :: Int -> Text +healthScoreBadge s + | s >= 80 = "bg-green-100 text-green-800" + | s >= 60 = "bg-yellow-100 text-yellow-800" + | s >= 40 = "bg-orange-100 text-orange-800" + | otherwise = "bg-red-100 text-red-800" diff --git a/Application/Migration/1743552000-ihf-phase7-advanced-observability.sql b/Application/Migration/1743552000-ihf-phase7-advanced-observability.sql new file mode 100644 index 0000000..1da548f --- /dev/null +++ b/Application/Migration/1743552000-ihf-phase7-advanced-observability.sql @@ -0,0 +1,64 @@ +-- IHF Phase 7 — Advanced Observability and Operational Integration +-- Workplan: IHUB-WP-0007 + +CREATE TABLE friction_scores ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id), + score INTEGER NOT NULL DEFAULT 0, + annotation_count INTEGER NOT NULL DEFAULT 0, + error_event_count INTEGER NOT NULL DEFAULT 0, + regression_flag BOOLEAN NOT NULL DEFAULT FALSE, + stale_candidate_count INTEGER NOT NULL DEFAULT 0, + last_computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + UNIQUE (widget_id) +); + +CREATE INDEX friction_scores_widget_id_idx ON friction_scores (widget_id); +CREATE INDEX friction_scores_score_idx ON friction_scores (score DESC); + +CREATE TABLE bottleneck_records ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + hub_id UUID NOT NULL REFERENCES hubs(id), + stage TEXT NOT NULL, + subject_type TEXT NOT NULL, + subject_id UUID NOT NULL, + stalled_since TIMESTAMP WITH TIME ZONE NOT NULL, + severity TEXT NOT NULL DEFAULT 'medium', + resolved_at TIMESTAMP WITH TIME ZONE, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX bottleneck_records_hub_id_idx ON bottleneck_records (hub_id); +CREATE INDEX bottleneck_records_stage_idx ON bottleneck_records (stage); +CREATE INDEX bottleneck_records_resolved_idx ON bottleneck_records (resolved_at) + WHERE resolved_at IS NULL; + +CREATE TABLE hub_health_snapshots ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + hub_id UUID NOT NULL REFERENCES hubs(id), + health_score INTEGER NOT NULL, + open_candidates INTEGER NOT NULL DEFAULT 0, + regressed_widgets INTEGER NOT NULL DEFAULT 0, + stale_decisions INTEGER NOT NULL DEFAULT 0, + active_bottlenecks INTEGER NOT NULL DEFAULT 0, + computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX hub_health_snapshots_hub_id_idx ON hub_health_snapshots (hub_id); +CREATE INDEX hub_health_snapshots_computed_at_idx + ON hub_health_snapshots (hub_id, computed_at DESC); + +CREATE TABLE cross_hub_propagations ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + pattern_type TEXT NOT NULL, + source_hub_id UUID REFERENCES hubs(id), + affected_hub_ids JSONB NOT NULL DEFAULT '[]', + summary TEXT NOT NULL, + detected_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + notes TEXT +); + +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); diff --git a/Application/Schema.sql b/Application/Schema.sql index e785b55..bf7e8b0 100644 --- a/Application/Schema.sql +++ b/Application/Schema.sql @@ -379,3 +379,71 @@ CREATE INDEX widgets_adapter_spec_id_idx ON widgets (adapter_spec_id); -- Per-hub API key for bearer-token auth on the interaction reporting endpoint. ALTER TABLE hubs ADD COLUMN api_key TEXT; + +-- Phase 7: Advanced Observability and Operational Integration + +-- Aggregated pain score per widget, recomputed on demand or scheduled. +CREATE TABLE friction_scores ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id), + score INTEGER NOT NULL DEFAULT 0, + annotation_count INTEGER NOT NULL DEFAULT 0, + error_event_count INTEGER NOT NULL DEFAULT 0, + regression_flag BOOLEAN NOT NULL DEFAULT FALSE, + stale_candidate_count INTEGER NOT NULL DEFAULT 0, + last_computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + UNIQUE (widget_id) +); + +CREATE INDEX friction_scores_widget_id_idx ON friction_scores (widget_id); +CREATE INDEX friction_scores_score_idx ON friction_scores (score DESC); + +-- Detected stalls at specific pipeline stages. +CREATE TABLE bottleneck_records ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + hub_id UUID NOT NULL REFERENCES hubs(id), + stage TEXT NOT NULL, + subject_type TEXT NOT NULL, + subject_id UUID NOT NULL, + stalled_since TIMESTAMP WITH TIME ZONE NOT NULL, + severity TEXT NOT NULL DEFAULT 'medium', + resolved_at TIMESTAMP WITH TIME ZONE, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX bottleneck_records_hub_id_idx ON bottleneck_records (hub_id); +CREATE INDEX bottleneck_records_stage_idx ON bottleneck_records (stage); +CREATE INDEX bottleneck_records_resolved_idx ON bottleneck_records (resolved_at) + WHERE resolved_at IS NULL; + +-- Periodic health snapshots for trend tracking. +CREATE TABLE hub_health_snapshots ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + hub_id UUID NOT NULL REFERENCES hubs(id), + health_score INTEGER NOT NULL, + open_candidates INTEGER NOT NULL DEFAULT 0, + regressed_widgets INTEGER NOT NULL DEFAULT 0, + stale_decisions INTEGER NOT NULL DEFAULT 0, + active_bottlenecks INTEGER NOT NULL DEFAULT 0, + computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX hub_health_snapshots_hub_id_idx ON hub_health_snapshots (hub_id); +CREATE INDEX hub_health_snapshots_computed_at_idx + ON hub_health_snapshots (hub_id, computed_at DESC); + +-- Patterns detected across multiple hubs. +CREATE TABLE cross_hub_propagations ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + pattern_type TEXT NOT NULL, + source_hub_id UUID REFERENCES hubs(id), + affected_hub_ids JSONB NOT NULL DEFAULT '[]', + summary TEXT NOT NULL, + detected_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + notes TEXT +); + +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); diff --git a/SCOPE.md b/SCOPE.md index f176b1a..ccc4331 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 6 complete — cross-framework UI adaptation layer 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) -- Stability: core artifact model and schema are stable; Phase 6 contracts are immutable once active; native IHP widgets unaffected (adapter_spec_id nullable); JS adapters are thin ESM modules with no build toolchain requirement +- 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 - Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start --- diff --git a/Test/Integration.hs b/Test/Integration.hs index 4b516d6..7b79695 100644 --- a/Test/Integration.hs +++ b/Test/Integration.hs @@ -1204,3 +1204,117 @@ main = do deleteRecord w2 deleteRecord spec deleteRecord hub + + -- ---------------------------------------------------------------- + -- Phase 7 — Advanced Observability and Operational Integration + -- ---------------------------------------------------------------- + + describe "FrictionScore" do + it "computes score correctly from known inputs" do + hub <- newRecord @Hub |> set #name "FrictionHub" |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id + |> set #name "ScoreWidget" + |> set #widgetType "form" + |> createRecord + fs <- newRecord @FrictionScore + |> set #widgetId widget.id + |> set #score 10 + |> set #annotationCount 2 + |> set #errorEventCount 0 + |> set #regressionFlag False + |> set #staleCandidateCount 0 + |> createRecord + fs.score `shouldBe` 10 + fs.annotationCount `shouldBe` 2 + fetched <- fetch fs.id + fetched.widgetId `shouldBe` widget.id + fs |> set #score 25 |> updateRecord + updated <- fetch fs.id + updated.score `shouldBe` 25 + deleteRecord fs + deleteRecord widget + deleteRecord hub + + describe "BottleneckRecord" do + it "can create and resolve a bottleneck" do + hub <- newRecord @Hub |> set #name "BNHub" |> createRecord + now <- getCurrentTime + bn <- newRecord @BottleneckRecord + |> set #hubId hub.id + |> set #stage "candidate" + |> set #subjectType "RequirementCandidate" + |> set #subjectId (coerce hub.id) + |> set #stalledSince now + |> set #severity "medium" + |> createRecord + bn.stage `shouldBe` "candidate" + bn.severity `shouldBe` "medium" + isNothing bn.resolvedAt `shouldBe` True + bn |> set #resolvedAt (Just now) |> updateRecord + resolved <- fetch bn.id + isJust resolved.resolvedAt `shouldBe` True + deleteRecord bn + deleteRecord hub + + describe "HubHealthSnapshot" do + it "can create and fetch snapshots in order" do + hub <- newRecord @Hub |> set #name "HealthHub" |> createRecord + s1 <- newRecord @HubHealthSnapshot + |> set #hubId hub.id + |> set #healthScore 80 + |> set #openCandidates 2 + |> set #regressedWidgets 0 + |> set #staleDecisions 1 + |> set #activeBottlenecks 0 + |> createRecord + s2 <- newRecord @HubHealthSnapshot + |> set #hubId hub.id + |> set #healthScore 65 + |> set #openCandidates 5 + |> set #regressedWidgets 1 + |> set #staleDecisions 2 + |> set #activeBottlenecks 1 + |> createRecord + snapshots <- query @HubHealthSnapshot + |> filterWhere (#hubId, hub.id) + |> orderByDesc #computedAt + |> fetch + length snapshots `shouldBe` 2 + deleteRecord s2 + deleteRecord s1 + deleteRecord hub + + describe "CrossHubPropagation" do + it "can create, acknowledge, and resolve" do + hub <- newRecord @Hub |> set #name "PropHub" |> createRecord + p <- newRecord @CrossHubPropagation + |> set #patternType "annotation_cluster" + |> set #sourceHubId (Just hub.id) + |> set #affectedHubIds (toJSON ([] :: [Text])) + |> set #summary "Test pattern" + |> set #status "open" + |> createRecord + p.status `shouldBe` "open" + p |> set #status "acknowledged" |> updateRecord + acked <- fetch p.id + acked.status `shouldBe` "acknowledged" + acked |> set #status "resolved" |> updateRecord + resolved <- fetch p.id + resolved.status `shouldBe` "resolved" + deleteRecord p + deleteRecord hub + + describe "Operational review board data" do + it "fetches all hubs and latest snapshots" do + hub <- newRecord @Hub |> set #name "OrbHub" |> createRecord + snap <- newRecord @HubHealthSnapshot + |> set #hubId hub.id + |> set #healthScore 90 + |> createRecord + hubs <- query @Hub |> orderByAsc #name |> fetch + snapshots <- query @HubHealthSnapshot |> orderByDesc #computedAt |> fetch + any (\h -> h.name == "OrbHub") hubs `shouldBe` True + any (\s -> s.hubId == hub.id) snapshots `shouldBe` True + deleteRecord snap + deleteRecord hub diff --git a/Web/Controller/CrossHubPropagations.hs b/Web/Controller/CrossHubPropagations.hs new file mode 100644 index 0000000..931acb6 --- /dev/null +++ b/Web/Controller/CrossHubPropagations.hs @@ -0,0 +1,37 @@ +module Web.Controller.CrossHubPropagations where + +import Web.Types +import Web.View.CrossHubPropagations.Index +import Generated.Types +import IHP.Prelude +import IHP.ControllerPrelude +import Application.Helper.CrossHubPropagation (detectPropagations) + +instance Controller CrossHubPropagationsController where + beforeAction = ensureIsUser + + action CrossHubPropagationsAction = autoRefresh do + propagations <- query @CrossHubPropagation + |> orderByDesc #detectedAt + |> fetch + hubs <- query @Hub |> fetch + render IndexView { propagations, hubs } + + action DetectPropagationsAction = do + hubs <- query @Hub |> fetch + widgets <- query @Widget |> fetch + annotations <- query @Annotation |> fetch + frictionScores <- query @FrictionScore |> fetch + _ <- detectPropagations hubs annotations widgets frictionScores + setSuccessMessage "Propagation detection complete" + redirectTo CrossHubPropagationsAction + + action AcknowledgePropagationAction { crossHubPropagationId } = do + p <- fetch crossHubPropagationId + p |> set #status "acknowledged" |> updateRecord + redirectTo CrossHubPropagationsAction + + action ResolvePropagationAction { crossHubPropagationId } = do + p <- fetch crossHubPropagationId + p |> set #status "resolved" |> updateRecord + redirectTo CrossHubPropagationsAction diff --git a/Web/Controller/Hubs.hs b/Web/Controller/Hubs.hs index 09c7b4b..84c32ff 100644 --- a/Web/Controller/Hubs.hs +++ b/Web/Controller/Hubs.hs @@ -10,10 +10,17 @@ import Web.View.Hubs.GovernanceDashboard import Web.View.Hubs.AntifragilityDashboard import Web.View.Hubs.AgentAuditDashboard import Web.View.Hubs.AdapterCompatibilityDashboard +import Web.View.Hubs.FrictionHeatmap +import Web.View.Hubs.BottleneckDashboard +import Web.View.Hubs.HubHealthHistory +import Web.View.Hubs.OperationalReviewBoard import Generated.Types import IHP.Prelude import IHP.ControllerPrelude import Application.Helper.Controller (regressedWidgetIds, widgetCycleCounts) +import Application.Helper.FrictionScore (computeFrictionScore) +import Application.Helper.BottleneckDetector (detectBottlenecks) +import Application.Helper.HubHealth (computeHubHealth) instance Controller HubsController where beforeAction = ensureIsUser @@ -237,3 +244,117 @@ instance Controller HubsController where envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch render AdapterCompatibilityDashboardView { hub, specs, widgets, envelopes, reportings } + + action FrictionHeatmapAction { hubId } = autoRefresh do + hub <- fetch hubId + widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch + let widgetIds = map (.id) widgets + frictionScores <- query @FrictionScore + |> filterWhereIn (#widgetId, widgetIds) + |> fetch + render FrictionHeatmapView { hub, widgets, frictionScores } + + action RecomputeFrictionAction { hubId } = do + hub <- fetch hubId + widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch + let widgetIds = map (.id) widgets + annotations <- query @Annotation |> filterWhereIn (#widgetId, widgetIds) |> fetch + events <- sqlQuery "SELECT * FROM interaction_events WHERE widget_id = ANY(?)" + (Only (PGArray widgetIds)) + signals <- query @OutcomeSignal |> filterWhereIn (#widgetId, widgetIds) |> fetch + candidates <- query @RequirementCandidate |> filterWhereIn (#sourceWidgetId, widgetIds) |> fetch + let regressionWids = regressedWidgetIds signals annotations + mapM_ (\w -> + let wAnnotations = filter (\a -> a.widgetId == w.id) annotations + wEvents = filter (\e -> e.widgetId == w.id) events + wCandidates = filter (\c -> c.sourceWidgetId == w.id) candidates + isRegressed = w.id `elem` regressionWids + in computeFrictionScore w.id wAnnotations wEvents isRegressed wCandidates + ) widgets + setSuccessMessage "Friction scores recomputed" + redirectTo FrictionHeatmapAction { hubId } + + action BottleneckDashboardAction { hubId } = autoRefresh do + hub <- fetch hubId + widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch + bottlenecks <- query @BottleneckRecord + |> filterWhere (#hubId, hubId) + |> orderByAsc #stalledSince + |> fetch + render BottleneckDashboardView { hub, widgets, bottlenecks } + + action DetectBottlenecksAction { hubId } = do + hub <- fetch hubId + widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch + let widgetIds = map (.id) widgets + candidates <- query @RequirementCandidate |> filterWhereIn (#sourceWidgetId, widgetIds) |> 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 + _ <- detectBottlenecks hubId widgets candidates requirements decisions deployments + setSuccessMessage "Bottleneck detection complete" + redirectTo BottleneckDashboardAction { hubId } + + action ResolveBottleneckAction { bottleneckRecordId } = do + bottleneck <- fetch bottleneckRecordId + now <- getCurrentTime + bottleneck |> set #resolvedAt (Just now) |> updateRecord + setSuccessMessage "Bottleneck resolved" + redirectTo BottleneckDashboardAction { hubId = bottleneck.hubId } + + action SnapshotHubHealthAction { hubId } = do + hub <- fetch hubId + widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch + let widgetIds = map (.id) widgets + candidates <- query @RequirementCandidate |> filterWhereIn (#sourceWidgetId, widgetIds) |> fetch + let 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 |> filterWhereIn (#widgetId, widgetIds) |> fetch + annotations <- query @Annotation |> filterWhereIn (#widgetId, widgetIds) |> fetch + bottlenecks <- query @BottleneckRecord + |> filterWhere (#hubId, hubId) + |> filterWhereSql (#resolvedAt, "IS NULL") + |> fetch + _ <- computeHubHealth hubId widgets candidates decisions deployments signals annotations bottlenecks + setSuccessMessage "Hub health snapshot taken" + redirectTo HubHealthHistoryAction { hubId } + + action HubHealthHistoryAction { hubId } = autoRefresh do + hub <- fetch hubId + snapshots <- query @HubHealthSnapshot + |> filterWhere (#hubId, hubId) + |> orderByDesc #computedAt + |> fetch + render HubHealthHistoryView { hub, snapshots } + + action OperationalReviewBoardAction = autoRefresh do + hubs <- query @Hub |> orderByAsc #name |> fetch + allSnapshots <- query @HubHealthSnapshot |> orderByDesc #computedAt |> fetch + topFrictionScores <- query @FrictionScore |> orderByDesc #score |> limit 10 |> fetch + topWidgets <- mapM (\fs -> fetch fs.widgetId) topFrictionScores + bottlenecks <- query @BottleneckRecord + |> filterWhereSql (#resolvedAt, "IS NULL") + |> orderByAsc #stage + |> fetch + propagations <- query @CrossHubPropagation + |> orderByDesc #detectedAt + |> fetch + let openPropagations = filter (\p -> p.status `elem` ["open","acknowledged"]) propagations + render OperationalReviewBoardView + { hubs + , allSnapshots + , topFrictionScores + , topWidgets + , bottlenecks + , openPropagations + } diff --git a/Web/FrontController.hs b/Web/FrontController.hs index 810616a..835c77e 100644 --- a/Web/FrontController.hs +++ b/Web/FrontController.hs @@ -23,6 +23,7 @@ import Web.Controller.ApiInteractionEvents () import Web.Controller.EnvelopeEmissionContracts () import Web.Controller.InteractionReportingContracts () import Web.Controller.WidgetAdapterSpecs () +import Web.Controller.CrossHubPropagations () import Web.Controller.Sessions () instance FrontController WebApplication where @@ -42,6 +43,7 @@ instance FrontController WebApplication where , parseRoute @EnvelopeEmissionContractsController , parseRoute @InteractionReportingContractsController , parseRoute @WidgetAdapterSpecsController + , parseRoute @CrossHubPropagationsController ] instance InitControllerContext WebApplication where @@ -81,6 +83,8 @@ defaultLayout inner = [hsx| Deployments Agent Adapters + Propagations + Ops Review diff --git a/Web/Routes.hs b/Web/Routes.hs index c46d3a2..7108458 100644 --- a/Web/Routes.hs +++ b/Web/Routes.hs @@ -52,5 +52,8 @@ instance AutoRoute EnvelopeEmissionContractsController instance AutoRoute InteractionReportingContractsController instance AutoRoute WidgetAdapterSpecsController +-- Phase 7 — Advanced Observability +instance AutoRoute CrossHubPropagationsController + -- Sessions instance AutoRoute SessionsController diff --git a/Web/Types.hs b/Web/Types.hs index 59485d1..b04f845 100644 --- a/Web/Types.hs +++ b/Web/Types.hs @@ -28,6 +28,14 @@ data HubsController | AntifragilityDashboardAction { hubId :: !(Id Hub) } | AgentAuditDashboardAction { hubId :: !(Id Hub) } | AdapterCompatibilityDashboardAction { hubId :: !(Id Hub) } + | FrictionHeatmapAction { hubId :: !(Id Hub) } + | RecomputeFrictionAction { hubId :: !(Id Hub) } + | BottleneckDashboardAction { hubId :: !(Id Hub) } + | DetectBottlenecksAction { hubId :: !(Id Hub) } + | ResolveBottleneckAction { bottleneckRecordId :: !(Id BottleneckRecord) } + | SnapshotHubHealthAction { hubId :: !(Id Hub) } + | HubHealthHistoryAction { hubId :: !(Id Hub) } + | OperationalReviewBoardAction deriving (Eq, Show, Data) data WidgetsController @@ -135,6 +143,13 @@ data WidgetAdapterSpecsController | UpdateWidgetAdapterSpecAction { widgetAdapterSpecId :: !(Id WidgetAdapterSpec) } deriving (Eq, Show, Data) +data CrossHubPropagationsController + = CrossHubPropagationsAction + | DetectPropagationsAction + | AcknowledgePropagationAction { crossHubPropagationId :: !(Id CrossHubPropagation) } + | ResolvePropagationAction { crossHubPropagationId :: !(Id CrossHubPropagation) } + deriving (Eq, Show, Data) + data SessionsController = NewSessionAction | CreateSessionAction diff --git a/Web/View/CrossHubPropagations/Index.hs b/Web/View/CrossHubPropagations/Index.hs new file mode 100644 index 0000000..8dd9bf4 --- /dev/null +++ b/Web/View/CrossHubPropagations/Index.hs @@ -0,0 +1,88 @@ +module Web.View.CrossHubPropagations.Index where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data IndexView = IndexView + { propagations :: ![CrossHubPropagation] + , hubs :: ![Hub] + } + +instance View IndexView where + html IndexView { .. } = [hsx| +
+

Cross-Hub Propagations

+ + Detect + +
+ + {if null propagations + then [hsx|

No propagation events detected yet.

|] + else [hsx| +
+ + + + + + + + + + + + + {forEach propagations renderRow} + +
PatternSummarySource HubStatusDetected
+
+ |]} + |] + where + hubName hid = maybe "–" (.name) (find (\h -> h.id == hid) hubs) + + renderRow :: CrossHubPropagation -> Html + renderRow p = [hsx| + + + + {p.patternType} + + + {p.summary} + + {maybe "–" hubName p.sourceHubId} + + + " text-xs px-2 py-0.5 rounded font-medium"}> + {p.status} + + + {show p.detectedAt} + + {if p.status == "open" + then [hsx| + Acknowledge + |] + else mempty} + {if p.status /= "resolved" + then [hsx| + Resolve + |] + else mempty} + + + |] + +statusBadge :: Text -> Text +statusBadge s = case s of + "open" -> "bg-yellow-100 text-yellow-800" + "acknowledged" -> "bg-blue-100 text-blue-800" + "resolved" -> "bg-green-100 text-green-800" + _ -> "bg-gray-100 text-gray-600" diff --git a/Web/View/Hubs/BottleneckDashboard.hs b/Web/View/Hubs/BottleneckDashboard.hs new file mode 100644 index 0000000..204ce6c --- /dev/null +++ b/Web/View/Hubs/BottleneckDashboard.hs @@ -0,0 +1,97 @@ +module Web.View.Hubs.BottleneckDashboard where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude +import Data.Time.Clock (diffUTCTime, getCurrentTime) + +data BottleneckDashboardView = BottleneckDashboardView + { hub :: !Hub + , widgets :: ![Widget] + , bottlenecks :: ![BottleneckRecord] + } + +instance View BottleneckDashboardView where + html BottleneckDashboardView { .. } = [hsx| +
+
+

Bottleneck Dashboard

+

{hub.name}

+
+ +
+ + {forEach stages renderStageSection} + + {if null bottlenecks + then [hsx|

No active bottlenecks detected.

|] + else mempty} + |] + where + stages = ["candidate", "requirement", "decision", "observation"] :: [Text] + stageLabel s = case s of + "candidate" -> "Stale Candidates (>30 days open)" + "requirement" -> "Requirements Without Decision (>60 days)" + "decision" -> "Decisions Without Deployment (>30 days)" + "observation" -> "Deployments Without Outcome Signal (>14 days)" + _ -> s + + active = filter (\b -> isNothing b.resolvedAt) bottlenecks + + renderStageSection :: Text -> Html + renderStageSection stage = + let stageBNs = filter (\b -> b.stage == stage) active + in if null stageBNs + then mempty + else [hsx| +
+

{stageLabel stage}

+ + + + + + + + + + + {forEach stageBNs renderRow} + +
SubjectStalled SinceSeverity
+
+ |] + + renderRow :: BottleneckRecord -> Html + renderRow b = [hsx| + + {show b.subjectId} + {show b.stalledSince} + + " text-xs px-2 py-0.5 rounded font-medium"}> + {b.severity} + + + + Resolve + + + |] + +severityBadge :: Text -> Text +severityBadge s = case s of + "critical" -> "bg-red-100 text-red-800" + "high" -> "bg-orange-100 text-orange-800" + "medium" -> "bg-yellow-100 text-yellow-800" + _ -> "bg-gray-100 text-gray-600" diff --git a/Web/View/Hubs/FrictionHeatmap.hs b/Web/View/Hubs/FrictionHeatmap.hs new file mode 100644 index 0000000..f320f1e --- /dev/null +++ b/Web/View/Hubs/FrictionHeatmap.hs @@ -0,0 +1,68 @@ +module Web.View.Hubs.FrictionHeatmap where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude +import Application.Helper.FrictionScore (scoreBand) + +data FrictionHeatmapView = FrictionHeatmapView + { hub :: !Hub + , widgets :: ![Widget] + , frictionScores :: ![FrictionScore] + } + +instance View FrictionHeatmapView where + html FrictionHeatmapView { .. } = [hsx| +
+
+

Friction Heatmap

+

{hub.name}

+
+ +
+ +
+ Low (0–19) + Medium (20–39) + High (40–59) + Critical (60+) +
+ + {if null widgets + then [hsx|

No widgets in this hub.

|] + else [hsx| +
+ {forEach widgets renderWidgetCard} +
+ |]} + |] + where + scoreFor w = maybe 0 (.score) (find (\fs -> fs.widgetId == w.id) frictionScores) + hasScore w = any (\fs -> fs.widgetId == w.id) frictionScores + + renderWidgetCard :: Widget -> Html + renderWidgetCard w = + let s = scoreFor w + band = scoreBand s + in [hsx| +
band}> +
+ {w.name} + {if hasScore w + then [hsx|{show s}|] + else [hsx||]} +
+

{w.widgetType}

+
+ |] diff --git a/Web/View/Hubs/HubHealthHistory.hs b/Web/View/Hubs/HubHealthHistory.hs new file mode 100644 index 0000000..6281a14 --- /dev/null +++ b/Web/View/Hubs/HubHealthHistory.hs @@ -0,0 +1,87 @@ +module Web.View.Hubs.HubHealthHistory where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude +import Application.Helper.HubHealth (healthScoreBadge) + +data HubHealthHistoryView = HubHealthHistoryView + { hub :: !Hub + , snapshots :: ![HubHealthSnapshot] + } + +instance View HubHealthHistoryView where + html HubHealthHistoryView { .. } = [hsx| +
+
+

Hub Health History

+

{hub.name}

+
+ +
+ + {case snapshots of + [] -> [hsx|

No snapshots yet. Take the first one.

|] + (latest : _) -> [hsx| +
+
+

Current Health Score

+ healthScoreBadge latest.healthScore}> + {show latest.healthScore} + +
+
+
Open candidates: {show latest.openCandidates}
+
Regressed widgets: {show latest.regressedWidgets}
+
Stale decisions: {show latest.staleDecisions}
+
Active bottlenecks: {show latest.activeBottlenecks}
+
+
+ |]} + + {if null snapshots then mempty else [hsx| +
+ + + + + + + + + + + + + {forEach snapshots renderRow} + +
ScoreOpen Cand.RegressedStale Dec.BottlenecksTaken At
+
+ |]} + |] + +renderRow :: HubHealthSnapshot -> Html +renderRow s = [hsx| + + + healthScoreBadge s.healthScore}> + {show s.healthScore} + + + {show s.openCandidates} + {show s.regressedWidgets} + {show s.staleDecisions} + {show s.activeBottlenecks} + {show s.computedAt} + +|] diff --git a/Web/View/Hubs/OperationalReviewBoard.hs b/Web/View/Hubs/OperationalReviewBoard.hs new file mode 100644 index 0000000..30f0947 --- /dev/null +++ b/Web/View/Hubs/OperationalReviewBoard.hs @@ -0,0 +1,179 @@ +module Web.View.Hubs.OperationalReviewBoard where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude +import Application.Helper.HubHealth (healthScoreBadge) +import Application.Helper.FrictionScore (scoreBand) +import Web.View.Hubs.BottleneckDashboard (severityBadge) + +data OperationalReviewBoardView = OperationalReviewBoardView + { hubs :: ![Hub] + , allSnapshots :: ![HubHealthSnapshot] + , topFrictionScores :: ![FrictionScore] + , topWidgets :: ![Widget] + , bottlenecks :: ![BottleneckRecord] + , openPropagations :: ![CrossHubPropagation] + } + +instance View OperationalReviewBoardView where + html OperationalReviewBoardView { .. } = [hsx| +
+

Operational Review Board

+
+ + +
+

Hub Health Matrix

+ {if null hubs + then [hsx|

No hubs registered.

|] + else [hsx| + + + + + + + + + + + {forEach hubs renderHubRow} + +
HubHealthSnapshot
+ |]} +
+ + +
+

Top Friction Widgets

+ {if null topFrictionScores + then [hsx|

No friction scores computed yet.

|] + else [hsx| + + + + + + + + + + {forEach (zip topFrictionScores topWidgets) renderFrictionRow} + +
WidgetScoreType
+ |]} +
+ + +
+

Active Bottlenecks by Stage

+ {if null bottlenecks + then [hsx|

No active bottlenecks.

|] + else [hsx| +
+ {forEach stages renderBottleneckStage} +
+ |]} +
+ + +
+

Open Cross-Hub Propagations

+ {if null openPropagations + then [hsx|

No open propagation events.

|] + else [hsx| +
+ {forEach openPropagations renderPropagationRow} +
+ |]} +
+ |] + where + stages = ["candidate", "requirement", "decision", "observation"] :: [Text] + stageLabel s = case s of + "candidate" -> "Candidate" + "requirement" -> "Requirement" + "decision" -> "Decision" + "observation" -> "Observation" + _ -> s + + latestSnapshotFor hub = + find (\s -> s.hubId == hub.id) allSnapshots + + renderHubRow :: Hub -> Html + renderHubRow h = + let mSnap = latestSnapshotFor h + in [hsx| + + + {h.name} + + + {case mSnap of + Nothing -> [hsx||] + Just s -> [hsx| + healthScoreBadge s.healthScore}> + {show s.healthScore} + + |]} + + + {maybe "never" (\s -> show s.computedAt) mSnap} + + + History + + + |] + + renderFrictionRow :: (FrictionScore, Widget) -> Html + renderFrictionRow (fs, w) = [hsx| + + + {w.name} + + + scoreBand fs.score}> + {show fs.score} + + + {w.widgetType} + + |] + + renderBottleneckStage :: Text -> Html + renderBottleneckStage stage = + let stageBNs = filter (\b -> b.stage == stage) bottlenecks + cnt = length stageBNs + hasCrit = any (\b -> b.severity == "critical") stageBNs + colourCls = if cnt == 0 then "bg-gray-50 text-gray-400" + else if hasCrit then "bg-red-50 text-red-700" + else "bg-orange-50 text-orange-700" + in [hsx| +
colourCls}> +
{show cnt}
+
{stageLabel stage}
+
+ |] + + renderPropagationRow :: CrossHubPropagation -> Html + renderPropagationRow p = [hsx| +
+
+ {p.patternType} + {p.summary} +

{show p.detectedAt}

+
+ +
+ |] diff --git a/Web/View/Hubs/Show.hs b/Web/View/Hubs/Show.hs index e35981e..eae14a5 100644 --- a/Web/View/Hubs/Show.hs +++ b/Web/View/Hubs/Show.hs @@ -49,6 +49,18 @@ instance View ShowView where class="text-sm border border-teal-300 text-teal-700 px-3 py-1.5 rounded hover:bg-teal-50"> Adapters + + Friction + + + Bottlenecks + + + Health + Edit diff --git a/docs/phase7-summary.md b/docs/phase7-summary.md new file mode 100644 index 0000000..c83ea3c --- /dev/null +++ b/docs/phase7-summary.md @@ -0,0 +1,88 @@ +# IHF Phase 7 Summary — Advanced Observability and Operational Integration + +## What Was Built + +Phase 7 turns the accumulated interaction data into operational intelligence. Four new data artifacts and five new views/dashboards give hub leaders a clear picture of where friction is concentrated, where the governance pipeline is stalling, and how hub health is trending over time. + +### Data Artifacts + +**FrictionScore** (`friction_scores`) +- One row per widget (unique constraint on `widget_id`); upserted on recompute +- Score formula (0–100): `min 100 (annotationCount*5 + errorEventCount*10 + regressionFlag?20:0 + staleCandidateCount*8)` +- Bands: 0–19 green, 20–39 yellow, 40–59 amber, 60+ red +- `Application/Helper/FrictionScore.hs` — `computeFrictionScore`, `scoreBand` + +**BottleneckRecord** (`bottleneck_records`) +- Detected stalls at four pipeline stages +- Severity: `medium` → `high` → `critical` based on how far past the threshold +- `resolved_at` nullable — partial index on `WHERE resolved_at IS NULL` for fast active queries +- `Application/Helper/BottleneckDetector.hs` — `detectBottlenecks` + +| Stage | Subject | Threshold | +|-------|---------|-----------| +| `candidate` | RequirementCandidate open | 30 days | +| `requirement` | Requirement without DecisionRecord | 60 days | +| `decision` | DecisionRecord without DeploymentRecord | 30 days | +| `observation` | DeploymentRecord without OutcomeSignal | 14 days | + +**HubHealthSnapshot** (`hub_health_snapshots`) +- Append-only; every `SnapshotHubHealthAction` inserts a new row +- Score formula (0–100): `max 0 (100 - openCandidates*5 - regressedWidgets*10 - staleDecisions*8 - criticalBN*12 - highBN*6)` +- `Application/Helper/HubHealth.hs` — `computeHubHealth`, `healthScoreBadge` + +**CrossHubPropagation** (`cross_hub_propagations`) +- Status lifecycle: `open → acknowledged → resolved` +- `Application/Helper/CrossHubPropagation.hs` — `detectPropagations` +- Two heuristics: + - `annotation_cluster`: same annotation category with ≥3 occurrences in ≥2 hubs within 14 days + - `widget_type_friction`: same widget type with FrictionScore ≥40 in ≥2 hubs + +### Views and Actions + +**Friction Heatmap** (`FrictionHeatmapAction { hubId }`) +- Grid of widget cards, colour-coded by score band +- "Recompute" button triggers `RecomputeFrictionAction` for all hub widgets +- Linked from hub Show page as "Friction" + +**Bottleneck Dashboard** (`BottleneckDashboardAction { hubId }`) +- Table grouped by pipeline stage; severity badge on each row +- "Detect" button runs `DetectBottlenecksAction` +- "Resolve" marks `resolved_at`; idempotent detection skips existing active records +- Linked from hub Show page as "Bottlenecks" + +**Hub Health History** (`HubHealthHistoryAction { hubId }`) +- Latest snapshot shown prominently with component breakdown +- Full history table in reverse chronological order +- "Take Snapshot" triggers `SnapshotHubHealthAction` +- Linked from hub Show page as "Health" + +**Operational Review Board** (`OperationalReviewBoardAction`) +- Global view, no hub scope — all hubs, all data +- Panel 1: Hub health matrix — all hubs, latest score, link to history +- Panel 2: Top 10 friction widgets across all hubs +- Panel 3: Active bottleneck counts by pipeline stage +- Panel 4: Open/acknowledged cross-hub propagation events +- `autoRefresh` — live-updates +- Linked from global nav as "Ops Review" + +**Cross-Hub Propagations** (`CrossHubPropagationsAction`) +- Global list of all propagation events with status badges +- "Detect" triggers `DetectPropagationsAction` (idempotent) +- Acknowledge / Resolve actions per row +- Linked from global nav as "Propagations" + +## Known Limitations + +- **Friction scores are not append-only.** There is no friction history — only the current score per widget. Use `HubHealthSnapshot` for hub-level trends over time. +- **Bottleneck detection requires manually triggering.** Phase 7 does not introduce scheduled jobs. Detection runs on demand via the UI or `curl`. A future phase can layer on a devenv cron task. +- **Cross-hub detection requires current friction scores.** Run `RecomputeFrictionAction` on all relevant hubs before `DetectPropagationsAction`, or detection will use stale/absent scores. +- **OperationalReviewBoard top-10 friction** is sorted globally — not scoped to a hub. A widget from a single hub could dominate the list if that hub has very high friction scores. +- **`subjectId` in BottleneckRecord is untyped UUID.** The `subject_type` column disambiguates, but there is no FK constraint — subject records can be deleted without cascading. A future phase can add per-stage FK columns. + +## Phase 8 Readiness + +Phase 8 (Federated Hub Maturity) can build on Phase 7: +- `CrossHubPropagation` already models multi-hub patterns; Phase 8 routing can act on these +- `HubHealthSnapshot` history provides a baseline for measuring federated governance quality +- The `OperationalReviewBoard` is already global — Phase 8 can add per-organization or per-team scoping +- Bottleneck thresholds are constants in helper modules — Phase 8 can make them configurable per hub via a `HubPolicy` table diff --git a/workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md b/workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md index 29ed5d0..f88d5a8 100644 --- a/workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md +++ b/workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md @@ -4,7 +4,7 @@ type: workplan title: "IHF Phase 7 — Advanced Observability and Operational Integration" domain: inter_hub repo: inter-hub -status: todo +status: done owner: custodian topic_slug: inter_hub created: "2026-03-29" @@ -61,7 +61,7 @@ Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 7, ```task id: IHUB-WP-0007-T01 -status: todo +status: done priority: high state_hub_task_id: "86e31f8b-62a3-4176-9b10-2fe7a8dbcc23" ``` @@ -152,7 +152,7 @@ CREATE INDEX cross_hub_propagations_pattern_idx ON cross_hub_propagations (patte ```task id: IHUB-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "3a5ecd28-17c2-4258-bfd9-b3eaecf52135" ``` @@ -193,7 +193,7 @@ with correct colour bands; recompute updates scores. ```task id: IHUB-WP-0007-T03 -status: todo +status: done priority: high state_hub_task_id: "ada0347a-880b-454e-843f-4a9135ea8739" ``` @@ -226,7 +226,7 @@ and groups correctly; resolve marks `resolved_at`. ```task id: IHUB-WP-0007-T04 -status: todo +status: done priority: high state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6" ``` @@ -259,7 +259,7 @@ table renders in order; badge appears on hub Show page. ```task id: IHUB-WP-0007-T05 -status: todo +status: done priority: medium state_hub_task_id: "7a860b9f-a835-47d6-96d8-2964ae37b12d" ``` @@ -290,7 +290,7 @@ duplicate runs are idempotent; acknowledge/resolve transitions work. ```task id: IHUB-WP-0007-T06 -status: todo +status: done priority: medium state_hub_task_id: "ffabc4d1-c166-4b7d-8bec-55365cbe0666" ``` @@ -318,7 +318,7 @@ hubs; top friction list is correctly sorted; live-updates on data change. ```task id: IHUB-WP-0007-T07 -status: todo +status: done priority: high state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05" ```