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: session_id:
max_iterations: 20 max_iterations: 20
completion_promise: "HEUREKA" completion_promise: "HEUREKA"
workplan_id: IHUB-WP-0006 workplan_id: IHUB-WP-0007
workplan_file: workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md workplan_file: workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md
started_at: "2026-03-29T21:00:29Z" started_at: "2026-03-29T21:41:13Z"
--- ---
## Workplan Status Check — Do This First, Every Iteration ## 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`): Count the task blocks (fenced code blocks with language tag `task`):
- How many tasks exist in total? - 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 ## Workplan: IHUB-WP-0007 — IHF Phase 7Advanced Observability and Operational Integration
**File:** `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md` **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 ## Goal
Ensure semantic continuity while the UI stack diversifies. Phase 5 established Integrate interaction governance with broader operational intelligence. Phase 6
AI-assisted distillation within the IHP server-rendered surface. Phase 6 ensures established cross-framework widget participation. Phase 7 turns the accumulated
that widget identity, interaction capture, and annotation capability are interaction data into operational intelligence: friction heatmaps, pipeline
preserved when UI components are authored outside of IHP HSX — React, Vue, or bottleneck detection, per-hub health scores, and cross-hub pattern propagation.
any JS-based component — without bypassing the IHF core. The capstone is an Operational Review Board dashboard that gives hub leaders a
unified view across all hubs.
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.
## Background ## Background
Phases 15 are complete. The IHF core (widget registry, interaction events, Phases 16 are complete. The IHF core (widget registry, interaction events,
annotations, requirements, decisions, outcomes, agent assistance) is stable. annotations, requirements, decisions, outcomes, agent assistance,
cross-framework adapters) is stable and extensible.
The spec (§Phase 6) calls for: The spec (§Phase 7) calls for:
- widget protocol adapters - Hub health correlation
- metadata emission standards - Policy violation correlation
- client-side SDKs or thin adapters - Workflow bottleneck analysis
- cross-framework annotation launcher - Interaction pain heatmaps
- standardized interaction reporting interface - Queue and job linkage
- Cross-hub issue propagation analysis
Artifacts introduced: `WidgetAdapterSpec`, `InteractionReportingContract`, Artifacts introduced: `FrictionScore`, `BottleneckRecord`, `HubHealthSnapshot`,
`EnvelopeEmissionContract`. `CrossHubPropagation`.
Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 6, Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 7,
`docs/ihp-overview.md`, `docs/ihp-controllers-views-forms.md`. `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 - Interaction data informs operational decision-making
- Widget identity remains stable across frontend evolution - Hub leaders can inspect systemic friction patterns
- Annotations and interaction events remain compatible - The platform supports cross-domain learning
## Data Artifacts Introduced (Phase 6) ## Data Artifacts Introduced (Phase 7)
`WidgetAdapterSpec`, `InteractionReportingContract`, `EnvelopeEmissionContract` `FrictionScore`, `BottleneckRecord`, `HubHealthSnapshot`, `CrossHubPropagation`
## Tasks ## Tasks
### T01 — Schema: WidgetAdapterSpec, InteractionReportingContract, EnvelopeEmissionContract ### T01 — Schema: FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation
```task ```task
id: IHUB-WP-0006-T01 id: IHUB-WP-0007-T01
status: todo status: todo
priority: high 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 ```sql
-- Describes how a specific UI technology (React, Vue, etc.) maps to IHF widget -- Aggregated pain score per widget, recomputed on demand or scheduled.
-- protocol obligations — identity, envelope emission, event reporting. CREATE TABLE friction_scores (
CREATE TABLE widget_adapter_specs (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE, -- e.g. "react-18", "vue-3", "web-component" widget_id UUID NOT NULL REFERENCES widgets(id),
framework TEXT NOT NULL, -- e.g. "react", "vue", "vanilla" score INTEGER NOT NULL DEFAULT 0,
version TEXT NOT NULL, -- adapter spec version, e.g. "1.0" -- 0100; higher = more friction
envelope_contract_id UUID REFERENCES envelope_emission_contracts(id), annotation_count INTEGER NOT NULL DEFAULT 0,
reporting_contract_id UUID REFERENCES interaction_reporting_contracts(id), error_event_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft', regression_flag BOOLEAN NOT NULL DEFAULT FALSE,
-- status values: draft | active | deprecated 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, 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 created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
); );
-- Standardised REST interface contract for external event and annotation CREATE INDEX bottleneck_records_hub_id_idx ON bottleneck_records (hub_id);
-- submission — used by non-IHP adapters. CREATE INDEX bottleneck_records_stage_idx ON bottleneck_records (stage);
CREATE TABLE interaction_reporting_contracts ( 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, id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0" hub_id UUID NOT NULL REFERENCES hubs(id),
endpoint_path TEXT NOT NULL, -- e.g. "/api/v1/interaction-events" health_score INTEGER NOT NULL,
accepted_event_types JSONB NOT NULL, -- e.g. ["clicked","viewed","submitted"] -- 0100
required_fields JSONB NOT NULL, open_candidates INTEGER NOT NULL DEFAULT 0,
-- minimum payload: widget_id, hub_id, event_type, occurred_at regressed_widgets INTEGER NOT NULL DEFAULT 0,
auth_scheme TEXT NOT NULL DEFAULT 'bearer', stale_decisions INTEGER NOT NULL DEFAULT 0,
description TEXT, active_bottlenecks INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
); );
-- Link widgets to their adapter spec (null = native IHP widget). CREATE INDEX hub_health_snapshots_hub_id_idx ON hub_health_snapshots (hub_id);
ALTER TABLE widgets CREATE INDEX hub_health_snapshots_computed_at_idx
ADD COLUMN adapter_spec_id UUID REFERENCES widget_adapter_specs(id); 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 ```task
id: IHUB-WP-0006-T02 id: IHUB-WP-0007-T02
status: todo status: todo
priority: high 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: 1. Add `Application/Helper/FrictionScore.hs` with `computeFrictionScore`:
- `required_attributes: ["data-widget-id", "data-view-context", "data-hub-id"]` - `annotation_count` — total annotations for widget
- `optional_attributes: ["data-policy-scope", "data-widget-version"]` - `error_event_count` — events with `event_type = 'errored'`
- `validation_rules: {data-widget-id: "uuid", data-hub-id: "uuid"}` - `regression_flag``True` if widget appears in `regressedWidgetIds`
2. Update the `widgetEnvelope` helper (`Web/View/Helpers.hs` or equivalent) to - `stale_candidate_count` — open candidates older than 30 days
read the active contract version from DB (or config) and assert required - Score formula (documented in module header):
attributes at render time — log a warning (not crash) if any are missing. ```
3. Add `EnvelopeEmissionContractsController`: score = min 100 $
- `index`: table of contract versions with status badges annotationCount * 5
- `show`: full required/optional attributes and validation rules as formatted + errorEventCount * 10
JSON panels + (if regressionFlag then 20 else 0)
- Read-only (contracts are immutable once active; a new version supersedes) + staleCandidateCount * 8
4. Link from global nav under "Contracts" ```
- 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 **Exit criteria:** Scores compute correctly for test fixtures; heatmap renders
against it; contract index/show pages render correctly. 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 ```task
id: IHUB-WP-0006-T03 id: IHUB-WP-0007-T03
status: todo status: todo
priority: high 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`: 1. Add `Application/Helper/BottleneckDetector.hs` with `detectBottlenecks`:
- `endpoint_path: "/api/v1/interaction-events"` - Stage 1 — `candidate`: `RequirementCandidate` with `status='open'` and
- `accepted_event_types: ["clicked","viewed","submitted","dismissed","errored"]` `created_at < now() - interval '30 days'`
- `required_fields: ["widget_id","hub_id","event_type","occurred_at"]` - Stage 2 — `requirement`: `Requirement` with no linked `DecisionRecord` and
2. Add `Api.InteractionEventsController` (separate from the web controller): `created_at < now() - interval '60 days'`
- `POST /api/v1/interaction-events` — JSON body, Bearer token auth - Stage 3 — `decision`: `DecisionRecord` with no linked `DeploymentRecord`
- Validate payload against the active `InteractionReportingContract` and `decided_at < now() - interval '30 days'`
- Create `InteractionEvent` record - Stage 4 — `observation`: `DeploymentRecord` with no linked `OutcomeSignal`
- Return `201 Created` with `{id, widget_id, event_type}` or `422` with and `deployed_at < now() - interval '14 days'`
validation errors - Severity: `critical` if age > 2× threshold, else `high` if > 1.5×, else `medium`
3. Register the API route in `FrontController.hs` - Upserts `BottleneckRecord` (skip if already exists for same subject)
4. Add `InteractionReportingContractsController` (read-only, same pattern as T02) 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 **Exit criteria:** Stale candidates create bottleneck records; dashboard renders
an `InteractionEvent`; invalid payloads return `422`; contract show page renders. and groups correctly; resolve marks `resolved_at`.
### T04 — WidgetAdapterSpecsController and registry dashboard ### T04 — Hub Health Correlation: composite health score and history
```task ```task
id: IHUB-WP-0006-T04 id: IHUB-WP-0007-T04
status: todo status: todo
priority: high priority: high
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db" state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6"
``` ```
1. Scaffold `WidgetAdapterSpecsController`: 1. Add `Application/Helper/HubHealth.hs` with `computeHubHealth`:
- `index`: table of adapters — framework badge, version, status, envelope - Deduction table (documented in module):
contract version, reporting contract version ```
- `new` / `create`: register a new adapter spec -5 per open RequirementCandidate
- `show`: full detail — framework, version, linked contracts, notes, status -10 per regressed widget
- `edit` / `update`: update notes and status only (contracts are immutable -8 per stale DecisionRecord (decided > 30 days, no deployment)
once linked) -12 per active critical BottleneckRecord
- No delete — adapter specs are audit artifacts -6 per active high BottleneckRecord
2. Validation: floor at 0
- `name`, `framework`, `version` required ```
- `status` must be `draft | active | deprecated` - Inserts new `HubHealthSnapshot` (never updates — history is append-only)
3. On widget `new`/`edit` forms: optional `adapter_spec_id` select (null = native) 2. Add `SnapshotHubHealthAction { hubId }` — computes and redirects to history
4. On widget show page: if `adapter_spec_id` present, show adapter badge with 3. Add `HubHealthHistoryAction { hubId }` view:
link to the spec - 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 **Exit criteria:** Snapshot computes correct score against test fixtures; history
form allows adapter assignment; widget show page renders adapter badge. 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 ```task
id: IHUB-WP-0006-T05 id: IHUB-WP-0007-T05
status: todo status: todo
priority: medium 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 1. Add `Application/Helper/CrossHubPropagation.hs` with `detectPropagations`:
module (no framework dependency): - **Annotation cluster heuristic**: for each annotation `category`, count
- On `DOMContentLoaded`, scan for elements with `data-widget-id` attribute distinct hubs with ≥3 annotations in that category in the last 14 days.
- Inject a small "annotate" trigger (button or icon) adjacent to each If ≥2 hubs qualify, emit a `CrossHubPropagation` with
enrolled element `pattern_type='annotation_cluster'` and a generated summary.
- On trigger click: open a lightweight inline form (textarea + category - **Widget type friction heuristic**: for each `widget_type`, count hubs
select) and POST to `/annotations` (existing IHP endpoint) via `fetch` where the max `FrictionScore` for that type is ≥40. If ≥2 hubs qualify,
- On success: show a brief confirmation; on error: show inline error message emit `pattern_type='widget_type_friction'`.
- Reads `data-hub-id` from the element (or nearest ancestor) for the hub - Skip if a matching open/acknowledged propagation already exists
context (idempotent detection)
2. The launcher must work in React-rendered pages where IHP does not own the 2. Add `DetectPropagationsAction` (global, no hubId) — runs detector
DOM — it relies solely on `data-widget-id` presence. 3. Add `CrossHubPropagationsAction` view (global):
3. Include as an optional script tag in the IHP layout (`Web/View/Layout.hs`) - Table: pattern type, source hub, affected hubs (comma list), summary,
with a feature flag (`IHP_ANNOTATION_LAUNCHER=true`) detected at, status badge
4. Document usage in `docs/annotation-launcher.md` - "Acknowledge" and "Resolve" actions
4. Link from global nav (alongside "Adapters", "Ops Review")
**Exit criteria:** Launcher script injects annotation triggers on a page with **Exit criteria:** Detection creates propagation records for qualifying patterns;
`data-widget-id` elements; annotation POST succeeds; works from a static HTML duplicate runs are idempotent; acknowledge/resolve transitions work.
test page (not IHP-rendered).
### T06 — React adapter specification and reference example ### T06 — Operational Review Board Dashboard: cross-hub unified view
```task ```task
id: IHUB-WP-0006-T06 id: IHUB-WP-0007-T06
status: todo status: todo
priority: medium 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 1. Add `OperationalReviewBoardAction` to a new `OperationsController`
admin UI): (or `HubsController` as a global action — no `hubId` parameter):
- links to envelope v1.0 contract and reporting v1.0 contract - **Panel 1 — Hub health matrix**: all hubs, latest health score (or ""),
- `status = active` colour-coded row, link to hub and to health history
2. Create `static/js/ihf-react-adapter.js` — a thin React hook + HOC: - **Panel 2 — Top friction widgets**: top 10 across all hubs by
- `useWidgetEnvelope(widgetId, hubId, viewContext)` — returns a `ref` and `FrictionScore.score DESC`; columns: widget name, hub, score band, link
`data-*` props object conforming to the envelope contract - **Panel 3 — Active bottlenecks by stage**: count of unresolved bottlenecks
- `withWidgetEnvelope(WrappedComponent, widgetId, hubId, viewContext)` — HOC per stage across all hubs; click-through to hub bottleneck dashboard
that applies the envelope to the root DOM element - **Panel 4 — Open cross-hub propagations**: list of open/acknowledged
- `useInteractionReporter(widgetId, hubId)` — returns a `reportEvent(type)` propagation events with pattern type and affected hub count
function that POSTs to `/api/v1/interaction-events` 2. `autoRefresh` — live-updates
3. Create `docs/react-adapter.md` with usage examples for all three exports 3. Link from global nav as "Ops Review"
4. Add a test fixture page in `static/` demonstrating a React widget using the 4. Link from global nav cross-hub propagation count badge if > 0
adapter alongside an IHP-rendered widget on the same page
**Exit criteria:** `useWidgetEnvelope` emits correct `data-*` attributes; **Exit criteria:** Dashboard renders all four panels; health matrix shows all
`reportEvent` reaches `/api/v1/interaction-events`; annotation launcher script hubs; top friction list is correctly sorted; live-updates on data change.
picks up the React widget's `data-widget-id`; docs written.
### T07 — Adapter compatibility validation dashboard ### T07 — Phase 7 gate: tests, consistency, docs
```task ```task
id: IHUB-WP-0006-T07 id: IHUB-WP-0007-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
status: todo status: todo
priority: high priority: high
state_hub_task_id: "90ea4814-7603-4016-be34-d41ae091f7e1" state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05"
``` ```
1. **Integration tests** (`Test/`): 1. **Integration tests** (`Test/`):
- EnvelopeEmissionContract create + fetch (required_attributes, validation_rules) - `FrictionScore` compute formula: widget with known annotation count →
- InteractionReportingContract create + fetch expected score
- `POST /api/v1/interaction-events` — valid payload creates InteractionEvent - `BottleneckRecord` create + resolve: stale candidate → bottleneck detected;
- `POST /api/v1/interaction-events` — missing required field returns 422 resolve sets `resolved_at`
- WidgetAdapterSpec create + status transition (draft → active → deprecated) - `HubHealthSnapshot` compute: hub with known candidates/regressions → expected
- Widget with adapter_spec_id: fetch + show renders adapter badge score; history fetch returns in order
- Adapter compatibility dashboard: compiles and returns correct widget counts - `CrossHubPropagation` create + acknowledge + resolve
- `OperationalReviewBoard` action: compiles, fetches all hubs, returns counts
2. **Consistency sync** via State Hub MCP: 2. **Consistency sync** via State Hub MCP:
`check_repo_consistency(repo_slug="inter-hub", fix=True)` `check_repo_consistency(repo_slug="inter-hub", fix=True)`
3. **Documentation updates:** 3. **Documentation updates:**
- Update `SCOPE.md` current state section: Phase 6 complete - Update `SCOPE.md` current state section: Phase 7 complete
- Write `docs/phase6-summary.md`: what was built, contract model, adapter - Write `docs/phase7-summary.md`: what was built, scoring formulae, bottleneck
pattern, known limitations, Phase 7 readiness thresholds, cross-hub heuristics, known limitations, Phase 8 readiness
4. **Smoke test checklist:** 4. **Smoke test checklist:**
- Register a `react-18` adapter spec via UI - Create two hubs with widgets and annotations; run friction recompute; verify
- Assign a widget to the adapter heatmap colours
- POST a test interaction event via `curl` to `/api/v1/interaction-events` - Age a candidate by force-setting `created_at`; run detect bottlenecks;
- Verify event appears in widget show page verify record appears
- Open annotation launcher on a page with a React-backed widget - Snapshot health for both hubs; verify Ops Review Board health matrix
- Confirm adapter compatibility dashboard shows correct coverage - 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 **Exit criteria:** All tests pass; consistency sync reports no errors; smoke
test completed; SCOPE.md updated. test completed; SCOPE.md updated.
## Phase 6 Dependencies ## Phase 7 Dependencies
- Phases 15 schema stable (widget registry, interaction events, and annotation - Phases 16 schema stable (widget registry, interaction events, annotations,
model required for adapter integration) requirements, decisions, outcomes, agent proposals, adapter specs)
- `envelope_emission_contracts` and `interaction_reporting_contracts` must exist - `friction_scores` requires widgets (T01 before T02)
before `widget_adapter_specs` (foreign key; T01 handles both in one migration) - `bottleneck_records` requires hubs, candidates, requirements, decisions,
- Contracts (T01T03) before adapter spec controller (T04) deployments (T01 before T03)
- Adapter spec controller (T04) before annotation launcher (T05) and React - `hub_health_snapshots` requires hubs and reads from bottleneck_records
adapter (T06) — widget assignment UI depends on T04 (T03 before T04)
- All feature tasks (T01T07) before gate (T08) - `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 ## Notes
- **Contracts are immutable once active.** A new version supersedes the old; - **Friction scores are recomputed, not append-only.** Each widget has at most
old versions remain readable for audit. No in-place edits after status=active. one `FrictionScore` row (unique constraint on `widget_id`). Historical trend
- **Native IHP widgets are unaffected.** `adapter_spec_id` is nullable. Existing is not tracked at the friction level — use `HubHealthSnapshot` for trends.
widgets continue to function exactly as before. - **Bottleneck detection is idempotent.** Re-running the detector skips records
- **The JS adapter is a thin client.** It does not embed a framework build where an unresolved bottleneck already exists for the same subject.
pipeline. `ihf-react-adapter.js` is a plain ESM module; consumers bundle it - **Health snapshots are append-only.** Every `SnapshotHubHealthAction` call
themselves. inserts a new row. This preserves the health history for trend analysis.
- **Auth for the reporting API.** Bearer token scheme. In Phase 6 the token - **Cross-hub detection requires FrictionScores to be current.** Run
is a per-hub API key stored in `hubs.api_key` (add column in T01 migration). `RecomputeFrictionAction` for all hubs before `DetectPropagationsAction`.
Phase 8 (federated) can layer on OAuth. - **No scheduled jobs in Phase 7.** Detection and recomputation are triggered
- **No local JS build toolchain added.** Static JS files are served as-is. manually via UI or curl. Phase 8 can layer on a cron/job system.
Phase 6 does not introduce npm, webpack, or esbuild into the IHP project. - **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>`, Before marking the workplan done and outputting `<promise>HEUREKA</promise>`,
verify all of the following are true: 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` 2. The workplan frontmatter `status` is `done`
3. The full test suite passes with no failures 3. The full test suite passes with no failures
4. The codebase passes the project's standard code-quality checks 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. -- Per-hub API key for bearer-token auth on the interaction reporting endpoint.
ALTER TABLE hubs ALTER TABLE hubs
ADD COLUMN api_key TEXT; 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 ## Current State
- Status: Phase 6 complete — cross-framework UI adaptation layer implemented - 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) - 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 (adapter_spec_id nullable); JS adapters are thin ESM modules with no build toolchain requirement - 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 - Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start
--- ---

View File

@@ -1204,3 +1204,117 @@ main = do
deleteRecord w2 deleteRecord w2
deleteRecord spec deleteRecord spec
deleteRecord hub 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.AntifragilityDashboard
import Web.View.Hubs.AgentAuditDashboard import Web.View.Hubs.AgentAuditDashboard
import Web.View.Hubs.AdapterCompatibilityDashboard 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 Generated.Types
import IHP.Prelude import IHP.Prelude
import IHP.ControllerPrelude import IHP.ControllerPrelude
import Application.Helper.Controller (regressedWidgetIds, widgetCycleCounts) 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 instance Controller HubsController where
beforeAction = ensureIsUser beforeAction = ensureIsUser
@@ -237,3 +244,117 @@ instance Controller HubsController where
envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch
reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch
render AdapterCompatibilityDashboardView { hub, specs, widgets, envelopes, reportings } 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.EnvelopeEmissionContracts ()
import Web.Controller.InteractionReportingContracts () import Web.Controller.InteractionReportingContracts ()
import Web.Controller.WidgetAdapterSpecs () import Web.Controller.WidgetAdapterSpecs ()
import Web.Controller.CrossHubPropagations ()
import Web.Controller.Sessions () import Web.Controller.Sessions ()
instance FrontController WebApplication where instance FrontController WebApplication where
@@ -42,6 +43,7 @@ instance FrontController WebApplication where
, parseRoute @EnvelopeEmissionContractsController , parseRoute @EnvelopeEmissionContractsController
, parseRoute @InteractionReportingContractsController , parseRoute @InteractionReportingContractsController
, parseRoute @WidgetAdapterSpecsController , parseRoute @WidgetAdapterSpecsController
, parseRoute @CrossHubPropagationsController
] ]
instance InitControllerContext WebApplication where 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={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={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={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"> <div class="ml-auto">
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a> <a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
</div> </div>

View File

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

View File

@@ -28,6 +28,14 @@ data HubsController
| AntifragilityDashboardAction { hubId :: !(Id Hub) } | AntifragilityDashboardAction { hubId :: !(Id Hub) }
| AgentAuditDashboardAction { hubId :: !(Id Hub) } | AgentAuditDashboardAction { hubId :: !(Id Hub) }
| AdapterCompatibilityDashboardAction { 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) deriving (Eq, Show, Data)
data WidgetsController data WidgetsController
@@ -135,6 +143,13 @@ data WidgetAdapterSpecsController
| UpdateWidgetAdapterSpecAction { widgetAdapterSpecId :: !(Id WidgetAdapterSpec) } | UpdateWidgetAdapterSpecAction { widgetAdapterSpecId :: !(Id WidgetAdapterSpec) }
deriving (Eq, Show, Data) deriving (Eq, Show, Data)
data CrossHubPropagationsController
= CrossHubPropagationsAction
| DetectPropagationsAction
| AcknowledgePropagationAction { crossHubPropagationId :: !(Id CrossHubPropagation) }
| ResolvePropagationAction { crossHubPropagationId :: !(Id CrossHubPropagation) }
deriving (Eq, Show, Data)
data SessionsController data SessionsController
= NewSessionAction = NewSessionAction
| CreateSessionAction | 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"> class="text-sm border border-teal-300 text-teal-700 px-3 py-1.5 rounded hover:bg-teal-50">
Adapters Adapters
</a> </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 }} <a href={EditHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50"> class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit 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" title: "IHF Phase 7 — Advanced Observability and Operational Integration"
domain: inter_hub domain: inter_hub
repo: inter-hub repo: inter-hub
status: todo status: done
owner: custodian owner: custodian
topic_slug: inter_hub topic_slug: inter_hub
created: "2026-03-29" created: "2026-03-29"
@@ -61,7 +61,7 @@ Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 7,
```task ```task
id: IHUB-WP-0007-T01 id: IHUB-WP-0007-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "86e31f8b-62a3-4176-9b10-2fe7a8dbcc23" 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 ```task
id: IHUB-WP-0007-T02 id: IHUB-WP-0007-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "3a5ecd28-17c2-4258-bfd9-b3eaecf52135" state_hub_task_id: "3a5ecd28-17c2-4258-bfd9-b3eaecf52135"
``` ```
@@ -193,7 +193,7 @@ with correct colour bands; recompute updates scores.
```task ```task
id: IHUB-WP-0007-T03 id: IHUB-WP-0007-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "ada0347a-880b-454e-843f-4a9135ea8739" state_hub_task_id: "ada0347a-880b-454e-843f-4a9135ea8739"
``` ```
@@ -226,7 +226,7 @@ and groups correctly; resolve marks `resolved_at`.
```task ```task
id: IHUB-WP-0007-T04 id: IHUB-WP-0007-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6" state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6"
``` ```
@@ -259,7 +259,7 @@ table renders in order; badge appears on hub Show page.
```task ```task
id: IHUB-WP-0007-T05 id: IHUB-WP-0007-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "7a860b9f-a835-47d6-96d8-2964ae37b12d" state_hub_task_id: "7a860b9f-a835-47d6-96d8-2964ae37b12d"
``` ```
@@ -290,7 +290,7 @@ duplicate runs are idempotent; acknowledge/resolve transitions work.
```task ```task
id: IHUB-WP-0007-T06 id: IHUB-WP-0007-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "ffabc4d1-c166-4b7d-8bec-55365cbe0666" 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 ```task
id: IHUB-WP-0007-T07 id: IHUB-WP-0007-T07
status: todo status: done
priority: high priority: high
state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05" state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05"
``` ```