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|
+
+
+ {if null propagations
+ then [hsx|No propagation events detected yet.
|]
+ else [hsx|
+
+
+
+
+ | Pattern |
+ Summary |
+ Source Hub |
+ Status |
+ Detected |
+ |
+
+
+
+ {forEach propagations renderRow}
+
+
+
+ |]}
+ |]
+ 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}
+
+
+
+ | Subject |
+ Stalled Since |
+ Severity |
+ |
+
+
+
+ {forEach stageBNs renderRow}
+
+
+
+ |]
+
+ 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|
+
+
+
+
+ | Score |
+ Open Cand. |
+ Regressed |
+ Stale Dec. |
+ Bottlenecks |
+ Taken At |
+
+
+
+ {forEach snapshots renderRow}
+
+
+
+ |]}
+ |]
+
+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|
+
+
+
+ | Hub |
+ Health |
+ Snapshot |
+ |
+
+
+
+ {forEach hubs renderHubRow}
+
+
+ |]}
+
+
+
+
+
Top Friction Widgets
+ {if null topFrictionScores
+ then [hsx|
No friction scores computed yet.
|]
+ else [hsx|
+
+
+
+ | Widget |
+ Score |
+ Type |
+
+
+
+ {forEach (zip topFrictionScores topWidgets) renderFrictionRow}
+
+
+ |]}
+
+
+
+
+
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"
```