feat(P7): IHF Phase 7 complete — advanced observability and operational integration
Some checks failed
Test / test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:49:22 +00:00
parent c0b4b984b0
commit 98fb159582
22 changed files with 1638 additions and 262 deletions

View File

@@ -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 6Cross-Framework UI Adaptation Layer
**File:** `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md`
## Workplan: IHUB-WP-0007 — IHF Phase 7Advanced Observability and Operational Integration
**File:** `workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md`
# IHF Phase 6Cross-Framework UI Adaptation Layer
# IHF Phase 7Advanced 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 15 are complete. The IHF core (widget registry, interaction events,
annotations, requirements, decisions, outcomes, agent assistance) is stable.
Phases 16 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,
-- 0100; 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,
-- 0100
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:
- 019: green (`bg-green-100`)
- 2039: yellow (`bg-yellow-100`)
- 4059: 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 15 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 (T01T03) 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 (T01T07) before gate (T08)
- Phases 16 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 (T02T05 before T06)
- All feature tasks (T01T06) 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 `<promise>HEUREKA</promise>`,
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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
---

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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|
<a href={DeploymentRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Deployments</a>
<a href={AgentProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Agent</a>
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-600 hover:text-gray-900">Adapters</a>
<a href={CrossHubPropagationsAction} class="text-sm text-gray-600 hover:text-gray-900">Propagations</a>
<a href={OperationalReviewBoardAction} class="text-sm text-gray-600 hover:text-gray-900">Ops Review</a>
<div class="ml-auto">
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
</div>

View File

@@ -52,5 +52,8 @@ instance AutoRoute EnvelopeEmissionContractsController
instance AutoRoute InteractionReportingContractsController
instance AutoRoute WidgetAdapterSpecsController
-- Phase 7 — Advanced Observability
instance AutoRoute CrossHubPropagationsController
-- Sessions
instance AutoRoute SessionsController

View File

@@ -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

View File

@@ -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|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Cross-Hub Propagations</h1>
<a href={DetectPropagationsAction}
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
Detect
</a>
</div>
{if null propagations
then [hsx|<p class="text-sm text-gray-400">No propagation events detected yet.</p>|]
else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Pattern</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Summary</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Source Hub</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Detected</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach propagations renderRow}
</tbody>
</table>
</div>
|]}
|]
where
hubName hid = maybe "" (.name) (find (\h -> h.id == hid) hubs)
renderRow :: CrossHubPropagation -> Html
renderRow p = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<span class="bg-purple-100 text-purple-700 text-xs px-1.5 py-0.5 rounded">
{p.patternType}
</span>
</td>
<td class="px-4 py-3 text-gray-700">{p.summary}</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{maybe "" hubName p.sourceHubId}
</td>
<td class="px-4 py-3">
<span class={statusBadge p.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{p.status}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-400">{show p.detectedAt}</td>
<td class="px-4 py-3 text-right">
{if p.status == "open"
then [hsx|
<a href={AcknowledgePropagationAction { crossHubPropagationId = p.id }}
class="text-xs text-yellow-600 hover:underline mr-2">Acknowledge</a>
|]
else mempty}
{if p.status /= "resolved"
then [hsx|
<a href={ResolvePropagationAction { crossHubPropagationId = p.id }}
class="text-xs text-green-600 hover:underline">Resolve</a>
|]
else mempty}
</td>
</tr>
|]
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"

View File

@@ -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|
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Bottleneck Dashboard</h1>
<p class="text-sm text-gray-500">{hub.name}</p>
</div>
<div class="flex gap-2">
<a href={DetectBottlenecksAction { hubId = hub.id }}
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
Detect
</a>
<a href={ShowHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Hub
</a>
</div>
</div>
{forEach stages renderStageSection}
{if null bottlenecks
then [hsx|<p class="text-sm text-gray-400 mt-4">No active bottlenecks detected.</p>|]
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|
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">{stageLabel stage}</h2>
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-3 py-2 font-medium text-gray-600">Subject</th>
<th class="text-left px-3 py-2 font-medium text-gray-600">Stalled Since</th>
<th class="text-left px-3 py-2 font-medium text-gray-600">Severity</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach stageBNs renderRow}
</tbody>
</table>
</div>
|]
renderRow :: BottleneckRecord -> Html
renderRow b = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 font-mono text-xs text-gray-500">{show b.subjectId}</td>
<td class="px-3 py-2 text-xs text-gray-500">{show b.stalledSince}</td>
<td class="px-3 py-2">
<span class={severityBadge b.severity <> " text-xs px-2 py-0.5 rounded font-medium"}>
{b.severity}
</span>
</td>
<td class="px-3 py-2 text-right">
<a href={ResolveBottleneckAction { bottleneckRecordId = b.id }}
class="text-xs text-indigo-600 hover:underline">Resolve</a>
</td>
</tr>
|]
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"

View File

@@ -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|
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Friction Heatmap</h1>
<p class="text-sm text-gray-500">{hub.name}</p>
</div>
<div class="flex gap-2">
<a href={RecomputeFrictionAction { hubId = hub.id }}
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
Recompute
</a>
<a href={ShowHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Hub
</a>
</div>
</div>
<div class="mb-4 flex gap-4 text-xs text-gray-500">
<span><span class="inline-block w-3 h-3 rounded bg-green-100 mr-1"></span>Low (019)</span>
<span><span class="inline-block w-3 h-3 rounded bg-yellow-100 mr-1"></span>Medium (2039)</span>
<span><span class="inline-block w-3 h-3 rounded bg-orange-100 mr-1"></span>High (4059)</span>
<span><span class="inline-block w-3 h-3 rounded bg-red-100 mr-1"></span>Critical (60+)</span>
</div>
{if null widgets
then [hsx|<p class="text-sm text-gray-400">No widgets in this hub.</p>|]
else [hsx|
<div class="grid grid-cols-3 gap-3">
{forEach widgets renderWidgetCard}
</div>
|]}
|]
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|
<div class={"rounded-lg border p-4 " <> band}>
<div class="flex items-start justify-between">
<a href={ShowWidgetAction { widgetId = w.id }}
class="font-medium text-sm hover:underline">{w.name}</a>
{if hasScore w
then [hsx|<span class="text-lg font-bold">{show s}</span>|]
else [hsx|<span class="text-xs text-gray-400"></span>|]}
</div>
<p class="text-xs mt-1 opacity-70">{w.widgetType}</p>
</div>
|]

View File

@@ -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|
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Hub Health History</h1>
<p class="text-sm text-gray-500">{hub.name}</p>
</div>
<div class="flex gap-2">
<a href={SnapshotHubHealthAction { hubId = hub.id }}
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
Take Snapshot
</a>
<a href={ShowHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Hub
</a>
</div>
</div>
{case snapshots of
[] -> [hsx|<p class="text-sm text-gray-400">No snapshots yet. Take the first one.</p>|]
(latest : _) -> [hsx|
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-6 flex items-center gap-6">
<div>
<p class="text-xs text-gray-500 mb-1">Current Health Score</p>
<span class={"text-3xl font-bold px-3 py-1 rounded " <> healthScoreBadge latest.healthScore}>
{show latest.healthScore}
</span>
</div>
<div class="text-sm text-gray-600 space-y-1">
<div>Open candidates: <strong>{show latest.openCandidates}</strong></div>
<div>Regressed widgets: <strong>{show latest.regressedWidgets}</strong></div>
<div>Stale decisions: <strong>{show latest.staleDecisions}</strong></div>
<div>Active bottlenecks: <strong>{show latest.activeBottlenecks}</strong></div>
</div>
</div>
|]}
{if null snapshots then mempty else [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">Score</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Open Cand.</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Regressed</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Stale Dec.</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Bottlenecks</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Taken At</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach snapshots renderRow}
</tbody>
</table>
</div>
|]}
|]
renderRow :: HubHealthSnapshot -> Html
renderRow s = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> healthScoreBadge s.healthScore}>
{show s.healthScore}
</span>
</td>
<td class="px-4 py-3 text-gray-700">{show s.openCandidates}</td>
<td class="px-4 py-3 text-gray-700">{show s.regressedWidgets}</td>
<td class="px-4 py-3 text-gray-700">{show s.staleDecisions}</td>
<td class="px-4 py-3 text-gray-700">{show s.activeBottlenecks}</td>
<td class="px-4 py-3 text-xs text-gray-400">{show s.computedAt}</td>
</tr>
|]

View File

@@ -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|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Operational Review Board</h1>
</div>
<!-- Panel 1: Hub health matrix -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Hub Health Matrix</h2>
{if null hubs
then [hsx|<p class="text-sm text-gray-400">No hubs registered.</p>|]
else [hsx|
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-3 py-2 font-medium text-gray-600">Hub</th>
<th class="text-left px-3 py-2 font-medium text-gray-600">Health</th>
<th class="text-left px-3 py-2 font-medium text-gray-600">Snapshot</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach hubs renderHubRow}
</tbody>
</table>
|]}
</div>
<!-- Panel 2: Top friction widgets -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Top Friction Widgets</h2>
{if null topFrictionScores
then [hsx|<p class="text-sm text-gray-400">No friction scores computed yet.</p>|]
else [hsx|
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-3 py-2 font-medium text-gray-600">Widget</th>
<th class="text-left px-3 py-2 font-medium text-gray-600">Score</th>
<th class="text-left px-3 py-2 font-medium text-gray-600">Type</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach (zip topFrictionScores topWidgets) renderFrictionRow}
</tbody>
</table>
|]}
</div>
<!-- Panel 3: Active bottlenecks by stage -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Active Bottlenecks by Stage</h2>
{if null bottlenecks
then [hsx|<p class="text-sm text-gray-400">No active bottlenecks.</p>|]
else [hsx|
<div class="grid grid-cols-4 gap-3">
{forEach stages renderBottleneckStage}
</div>
|]}
</div>
<!-- Panel 4: Open cross-hub propagations -->
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Open Cross-Hub Propagations</h2>
{if null openPropagations
then [hsx|<p class="text-sm text-gray-400">No open propagation events.</p>|]
else [hsx|
<div class="space-y-2">
{forEach openPropagations renderPropagationRow}
</div>
|]}
</div>
|]
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|
<tr class="hover:bg-gray-50">
<td class="px-3 py-2">
<a href={ShowHubAction { hubId = h.id }}
class="text-indigo-600 hover:underline">{h.name}</a>
</td>
<td class="px-3 py-2">
{case mSnap of
Nothing -> [hsx|<span class="text-xs text-gray-400"></span>|]
Just s -> [hsx|
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> healthScoreBadge s.healthScore}>
{show s.healthScore}
</span>
|]}
</td>
<td class="px-3 py-2 text-xs text-gray-400">
{maybe "never" (\s -> show s.computedAt) mSnap}
</td>
<td class="px-3 py-2 text-right">
<a href={HubHealthHistoryAction { hubId = h.id }}
class="text-xs text-indigo-600 hover:underline">History</a>
</td>
</tr>
|]
renderFrictionRow :: (FrictionScore, Widget) -> Html
renderFrictionRow (fs, w) = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-3 py-2">
<a href={ShowWidgetAction { widgetId = w.id }}
class="text-indigo-600 hover:underline">{w.name}</a>
</td>
<td class="px-3 py-2">
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> scoreBand fs.score}>
{show fs.score}
</span>
</td>
<td class="px-3 py-2 text-gray-500 text-xs">{w.widgetType}</td>
</tr>
|]
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|
<div class={"rounded-lg p-4 text-center " <> colourCls}>
<div class="text-2xl font-bold">{show cnt}</div>
<div class="text-xs mt-1">{stageLabel stage}</div>
</div>
|]
renderPropagationRow :: CrossHubPropagation -> Html
renderPropagationRow p = [hsx|
<div class="flex items-start justify-between p-3 bg-gray-50 rounded border border-gray-200">
<div>
<span class="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded mr-2">{p.patternType}</span>
<span class="text-sm text-gray-700">{p.summary}</span>
<p class="text-xs text-gray-400 mt-0.5">{show p.detectedAt}</p>
</div>
<div class="flex gap-2 ml-4">
<a href={AcknowledgePropagationAction { crossHubPropagationId = p.id }}
class="text-xs text-yellow-600 hover:underline whitespace-nowrap">Acknowledge</a>
<a href={ResolvePropagationAction { crossHubPropagationId = p.id }}
class="text-xs text-green-600 hover:underline">Resolve</a>
</div>
</div>
|]

View File

@@ -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
</a>
<a href={FrictionHeatmapAction { hubId = hub.id }}
class="text-sm border border-orange-300 text-orange-700 px-3 py-1.5 rounded hover:bg-orange-50">
Friction
</a>
<a href={BottleneckDashboardAction { hubId = hub.id }}
class="text-sm border border-red-300 text-red-700 px-3 py-1.5 rounded hover:bg-red-50">
Bottlenecks
</a>
<a href={HubHealthHistoryAction { hubId = hub.id }}
class="text-sm border border-green-300 text-green-700 px-3 py-1.5 rounded hover:bg-green-50">
Health
</a>
<a href={EditHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit

88
docs/phase7-summary.md Normal file
View File

@@ -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 (0100): `min 100 (annotationCount*5 + errorEventCount*10 + regressionFlag?20:0 + staleCandidateCount*8)`
- Bands: 019 green, 2039 yellow, 4059 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 (0100): `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

View File

@@ -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"
```