feat(P6/T02-T03): EnvelopeEmissionContract and InteractionReportingContract

T02: EnvelopeEmissionContractsController (index+show, read-only); widgetEnvelope
helper validates against contract v1.0 required attributes with inline warning
on missing view-context; adapterStatusBadge helper added to Application.Helper.View.

T03: InteractionReportingContractsController (index+show, read-only); API endpoint
POST /api/v1/interaction-events with bearer token auth against hub.api_key,
contract v1.0 field validation, and 201/422/401 responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:11:03 +00:00
parent 55af11342d
commit 14779f0768
13 changed files with 898 additions and 4 deletions

419
.claude/ralph-loop.local.md Normal file
View File

@@ -0,0 +1,419 @@
---
active: true
iteration: 1
session_id:
max_iterations: 20
completion_promise: "HEUREKA"
workplan_id: IHUB-WP-0006
workplan_file: workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md
started_at: "2026-03-29T21:00:29Z"
---
## Workplan Status Check — Do This First, Every Iteration
Read the workplan file at: `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md`
Count the task blocks (fenced code blocks with language tag `task`):
- How many tasks exist in total?
- How many have `status: done`?
If **every task** has `status: done` AND the frontmatter `status` is `done`:
The workplan is complete. Output exactly: <promise>HEUREKA</promise>
Do nothing else. Stop here.
Otherwise: continue with the implementation below.
---
## Workplan: IHUB-WP-0006 — IHF Phase 6 — Cross-Framework UI Adaptation Layer
**File:** `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md`
# IHF Phase 6 — Cross-Framework UI Adaptation Layer
## Goal
Ensure semantic continuity while the UI stack diversifies. Phase 5 established
AI-assisted distillation within the IHP server-rendered surface. Phase 6 ensures
that widget identity, interaction capture, and annotation capability are
preserved when UI components are authored outside of IHP HSX — React, Vue, or
any JS-based component — without bypassing the IHF core.
All Phase 6 artifacts are formal contracts rather than free-form conventions.
A widget that participates via an adapter must honour the same identity,
traceability, and event-capture obligations as a native IHP widget.
## Background
Phases 15 are complete. The IHF core (widget registry, interaction events,
annotations, requirements, decisions, outcomes, agent assistance) is stable.
The spec (§Phase 6) calls for:
- widget protocol adapters
- metadata emission standards
- client-side SDKs or thin adapters
- cross-framework annotation launcher
- standardized interaction reporting interface
Artifacts introduced: `WidgetAdapterSpec`, `InteractionReportingContract`,
`EnvelopeEmissionContract`.
Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 6,
`docs/ihp-overview.md`, `docs/ihp-controllers-views-forms.md`.
## Phase 6 Exit Criteria (from IHF spec §Phase 6)
- New UI technologies can participate without bypassing the IHF core
- Widget identity remains stable across frontend evolution
- Annotations and interaction events remain compatible
## Data Artifacts Introduced (Phase 6)
`WidgetAdapterSpec`, `InteractionReportingContract`, `EnvelopeEmissionContract`
## Tasks
### T01 — Schema: WidgetAdapterSpec, InteractionReportingContract, EnvelopeEmissionContract
```task
id: IHUB-WP-0006-T01
status: todo
priority: high
state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7"
```
Add Phase 6 tables to `Application/Schema.sql` and write migration:
```sql
-- Describes how a specific UI technology (React, Vue, etc.) maps to IHF widget
-- protocol obligations — identity, envelope emission, event reporting.
CREATE TABLE widget_adapter_specs (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE, -- e.g. "react-18", "vue-3", "web-component"
framework TEXT NOT NULL, -- e.g. "react", "vue", "vanilla"
version TEXT NOT NULL, -- adapter spec version, e.g. "1.0"
envelope_contract_id UUID REFERENCES envelope_emission_contracts(id),
reporting_contract_id UUID REFERENCES interaction_reporting_contracts(id),
status TEXT NOT NULL DEFAULT 'draft',
-- status values: draft | active | deprecated
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX widget_adapter_specs_framework_idx ON widget_adapter_specs (framework);
CREATE INDEX widget_adapter_specs_status_idx ON widget_adapter_specs (status);
-- Formalises the rules for how a widget envelope must be emitted:
-- which attributes are required, their format, and version.
CREATE TABLE envelope_emission_contracts (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0", "1.1"
required_attributes JSONB NOT NULL,
-- e.g. ["data-widget-id", "data-view-context", "data-hub-id"]
optional_attributes JSONB NOT NULL DEFAULT '[]',
validation_rules JSONB NOT NULL DEFAULT '{}',
-- machine-readable rules: format checks, presence guards
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
-- status values: draft | active | superseded
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Standardised REST interface contract for external event and annotation
-- submission — used by non-IHP adapters.
CREATE TABLE interaction_reporting_contracts (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0"
endpoint_path TEXT NOT NULL, -- e.g. "/api/v1/interaction-events"
accepted_event_types JSONB NOT NULL, -- e.g. ["clicked","viewed","submitted"]
required_fields JSONB NOT NULL,
-- minimum payload: widget_id, hub_id, event_type, occurred_at
auth_scheme TEXT NOT NULL DEFAULT 'bearer',
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Link widgets to their adapter spec (null = native IHP widget).
ALTER TABLE widgets
ADD COLUMN adapter_spec_id UUID REFERENCES widget_adapter_specs(id);
CREATE INDEX widgets_adapter_spec_id_idx ON widgets (adapter_spec_id);
```
**Exit criteria:** `migrate` runs cleanly; all Phase 6 types available in GHCi.
### T02 — EnvelopeEmissionContract: formalise widgetEnvelope as a versioned contract
```task
id: IHUB-WP-0006-T02
status: todo
priority: high
state_hub_task_id: "298af675-550b-480b-bed6-05efc79cd0c9"
```
1. Seed the canonical v1.0 `EnvelopeEmissionContract` record in a migration:
- `required_attributes: ["data-widget-id", "data-view-context", "data-hub-id"]`
- `optional_attributes: ["data-policy-scope", "data-widget-version"]`
- `validation_rules: {data-widget-id: "uuid", data-hub-id: "uuid"}`
2. Update the `widgetEnvelope` helper (`Web/View/Helpers.hs` or equivalent) to
read the active contract version from DB (or config) and assert required
attributes at render time — log a warning (not crash) if any are missing.
3. Add `EnvelopeEmissionContractsController`:
- `index`: table of contract versions with status badges
- `show`: full required/optional attributes and validation rules as formatted
JSON panels
- Read-only (contracts are immutable once active; a new version supersedes)
4. Link from global nav under "Contracts"
**Exit criteria:** Active contract record exists in DB; widgetEnvelope validates
against it; contract index/show pages render correctly.
### T03 — InteractionReportingContract: REST endpoint for external event submission
```task
id: IHUB-WP-0006-T03
status: todo
priority: high
state_hub_task_id: "f2767465-ff00-48be-b2dc-5bf3b179cca9"
```
1. Seed the canonical v1.0 `InteractionReportingContract`:
- `endpoint_path: "/api/v1/interaction-events"`
- `accepted_event_types: ["clicked","viewed","submitted","dismissed","errored"]`
- `required_fields: ["widget_id","hub_id","event_type","occurred_at"]`
2. Add `Api.InteractionEventsController` (separate from the web controller):
- `POST /api/v1/interaction-events` — JSON body, Bearer token auth
- Validate payload against the active `InteractionReportingContract`
- Create `InteractionEvent` record
- Return `201 Created` with `{id, widget_id, event_type}` or `422` with
validation errors
3. Register the API route in `FrontController.hs`
4. Add `InteractionReportingContractsController` (read-only, same pattern as T02)
**Exit criteria:** `POST /api/v1/interaction-events` with a valid payload creates
an `InteractionEvent`; invalid payloads return `422`; contract show page renders.
### T04 — WidgetAdapterSpecsController and registry dashboard
```task
id: IHUB-WP-0006-T04
status: todo
priority: high
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
```
1. Scaffold `WidgetAdapterSpecsController`:
- `index`: table of adapters — framework badge, version, status, envelope
contract version, reporting contract version
- `new` / `create`: register a new adapter spec
- `show`: full detail — framework, version, linked contracts, notes, status
- `edit` / `update`: update notes and status only (contracts are immutable
once linked)
- No delete — adapter specs are audit artifacts
2. Validation:
- `name`, `framework`, `version` required
- `status` must be `draft | active | deprecated`
3. On widget `new`/`edit` forms: optional `adapter_spec_id` select (null = native)
4. On widget show page: if `adapter_spec_id` present, show adapter badge with
link to the spec
**Exit criteria:** Adapter specs can be registered, listed, and viewed; widget
form allows adapter assignment; widget show page renders adapter badge.
### T05 — Cross-framework annotation launcher (lightweight JS widget)
```task
id: IHUB-WP-0006-T05
status: todo
priority: medium
state_hub_task_id: "fea86955-d5e6-4623-b5cc-f422c266c9cf"
```
1. Create `static/js/ihf-annotation-launcher.js` — a self-contained vanilla JS
module (no framework dependency):
- On `DOMContentLoaded`, scan for elements with `data-widget-id` attribute
- Inject a small "annotate" trigger (button or icon) adjacent to each
enrolled element
- On trigger click: open a lightweight inline form (textarea + category
select) and POST to `/annotations` (existing IHP endpoint) via `fetch`
- On success: show a brief confirmation; on error: show inline error message
- Reads `data-hub-id` from the element (or nearest ancestor) for the hub
context
2. The launcher must work in React-rendered pages where IHP does not own the
DOM — it relies solely on `data-widget-id` presence.
3. Include as an optional script tag in the IHP layout (`Web/View/Layout.hs`)
with a feature flag (`IHP_ANNOTATION_LAUNCHER=true`)
4. Document usage in `docs/annotation-launcher.md`
**Exit criteria:** Launcher script injects annotation triggers on a page with
`data-widget-id` elements; annotation POST succeeds; works from a static HTML
test page (not IHP-rendered).
### T06 — React adapter specification and reference example
```task
id: IHUB-WP-0006-T06
status: todo
priority: medium
state_hub_task_id: "023269d8-9835-40b4-a394-478a0f36eee0"
```
1. Register a `react-18` `WidgetAdapterSpec` record (via migration seed or
admin UI):
- links to envelope v1.0 contract and reporting v1.0 contract
- `status = active`
2. Create `static/js/ihf-react-adapter.js` — a thin React hook + HOC:
- `useWidgetEnvelope(widgetId, hubId, viewContext)` — returns a `ref` and
`data-*` props object conforming to the envelope contract
- `withWidgetEnvelope(WrappedComponent, widgetId, hubId, viewContext)` — HOC
that applies the envelope to the root DOM element
- `useInteractionReporter(widgetId, hubId)` — returns a `reportEvent(type)`
function that POSTs to `/api/v1/interaction-events`
3. Create `docs/react-adapter.md` with usage examples for all three exports
4. Add a test fixture page in `static/` demonstrating a React widget using the
adapter alongside an IHP-rendered widget on the same page
**Exit criteria:** `useWidgetEnvelope` emits correct `data-*` attributes;
`reportEvent` reaches `/api/v1/interaction-events`; annotation launcher script
picks up the React widget's `data-widget-id`; docs written.
### T07 — Adapter compatibility validation dashboard
```task
id: IHUB-WP-0006-T07
status: todo
priority: medium
state_hub_task_id: "dc8fa48a-7195-4410-a77e-717b53127c2e"
```
1. Add `AdapterCompatibilityDashboardAction { hubId }` to `HubsController`
(AutoRefresh):
- **Adapter summary**: count of registered adapters by status
(draft / active / deprecated)
- **Widget coverage**: total widgets / native IHP / adapter-backed (per
adapter spec), with percentage bars
- **Contract versions in use**: which envelope and reporting contract
versions are active
- **Unassigned widgets**: widgets with no `adapter_spec_id` that have
received events from external origins (heuristic: `user_agent` not
matching IHP server)
- **Stale adapters**: adapter specs with `status=active` but no widgets
assigned in the last 30 days
2. Link from hub Show page alongside Triage / Governance / Antifragility /
Agent dashboards
3. Add "Adapters" link to global nav
**Exit criteria:** Dashboard renders all five panels; live-updates on widget or
adapter changes; stale adapter detection works.
### T08 — Phase 6 gate: tests, consistency, docs
```task
id: IHUB-WP-0006-T08
status: todo
priority: high
state_hub_task_id: "90ea4814-7603-4016-be34-d41ae091f7e1"
```
1. **Integration tests** (`Test/`):
- EnvelopeEmissionContract create + fetch (required_attributes, validation_rules)
- InteractionReportingContract create + fetch
- `POST /api/v1/interaction-events` — valid payload creates InteractionEvent
- `POST /api/v1/interaction-events` — missing required field returns 422
- WidgetAdapterSpec create + status transition (draft → active → deprecated)
- Widget with adapter_spec_id: fetch + show renders adapter badge
- Adapter compatibility dashboard: compiles and returns correct widget counts
2. **Consistency sync** via State Hub MCP:
`check_repo_consistency(repo_slug="inter-hub", fix=True)`
3. **Documentation updates:**
- Update `SCOPE.md` current state section: Phase 6 complete
- Write `docs/phase6-summary.md`: what was built, contract model, adapter
pattern, known limitations, Phase 7 readiness
4. **Smoke test checklist:**
- Register a `react-18` adapter spec via UI
- Assign a widget to the adapter
- POST a test interaction event via `curl` to `/api/v1/interaction-events`
- Verify event appears in widget show page
- Open annotation launcher on a page with a React-backed widget
- Confirm adapter compatibility dashboard shows correct coverage
**Exit criteria:** All tests pass; consistency sync reports no errors; smoke
test completed; SCOPE.md updated.
## Phase 6 Dependencies
- Phases 15 schema stable (widget registry, interaction events, and annotation
model required for adapter integration)
- `envelope_emission_contracts` and `interaction_reporting_contracts` must exist
before `widget_adapter_specs` (foreign key; T01 handles both in one migration)
- Contracts (T01T03) before adapter spec controller (T04)
- Adapter spec controller (T04) before annotation launcher (T05) and React
adapter (T06) — widget assignment UI depends on T04
- All feature tasks (T01T07) before gate (T08)
## Notes
- **Contracts are immutable once active.** A new version supersedes the old;
old versions remain readable for audit. No in-place edits after status=active.
- **Native IHP widgets are unaffected.** `adapter_spec_id` is nullable. Existing
widgets continue to function exactly as before.
- **The JS adapter is a thin client.** It does not embed a framework build
pipeline. `ihf-react-adapter.js` is a plain ESM module; consumers bundle it
themselves.
- **Auth for the reporting API.** Bearer token scheme. In Phase 6 the token
is a per-hub API key stored in `hubs.api_key` (add column in T01 migration).
Phase 8 (federated) can layer on OAuth.
- **No local JS build toolchain added.** Static JS files are served as-is.
Phase 6 does not introduce npm, webpack, or esbuild into the IHP project.
---
## How to Work
- Stay strictly within the scope of the workplan above
- Work through tasks in priority order (high → medium → low)
- Use TDD where applicable: write a failing test, make it pass, then refactor
- Use whatever test runner, linter, and build tools this repository already uses
- Consult existing documentation (README, docs/, wiki/, specs/) for context
- Document significant architecture decisions as ADRs if the project uses them
## Updating Task Status
As you complete each task, edit the workplan file to update its status:
```
status: todo → status: in_progress (when you start it)
status: in_progress → status: done (when it is verified complete)
```
When **every task** is `done`, also update the workplan frontmatter:
```
status: active → status: done
```
## Success Criteria
Before marking the workplan done and outputting `<promise>HEUREKA</promise>`,
verify all of the following are true:
1. Every task block in `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md` has `status: done`
2. The workplan frontmatter `status` is `done`
3. The full test suite passes with no failures
4. The codebase passes the project's standard code-quality checks
(linting, type checking, formatting — whatever applies to this project)
5. Documentation reflects the implemented behaviour
Output `<promise>HEUREKA</promise>` only when all five are genuinely true.

View File

@@ -11,6 +11,15 @@ import Web.Types
-- client-side event capture script reads to identify the widget without coupling -- client-side event capture script reads to identify the widget without coupling
-- to implementation details. -- to implementation details.
-- --
-- The envelope is validated against the v1.0 EnvelopeEmissionContract at render
-- time. Missing required attributes are surfaced as an inline warning banner
-- (development) rather than a hard failure, so layout is preserved in production.
--
-- Required attributes (contract v1.0):
-- data-widget-id — stable UUID from the widget registry
-- data-view-context — logical UI location
-- data-hub-id — owning hub UUID
--
-- Usage: -- Usage:
-- --
-- @ -- @
@@ -18,10 +27,10 @@ import Web.Types
-- <button>Click me</button> -- <button>Click me</button>
-- |] -- |]
-- @ -- @
--
-- See docs/widget-envelope-convention.md for the full convention.
widgetEnvelope :: Widget -> Html -> Html widgetEnvelope :: Widget -> Html -> Html
widgetEnvelope widget inner = [hsx| widgetEnvelope widget inner =
let warnings = envelopeContractWarnings widget
in [hsx|
<div <div
class="ihf-widget" class="ihf-widget"
data-widget-id={tshow widget.id} data-widget-id={tshow widget.id}
@@ -32,6 +41,7 @@ widgetEnvelope widget inner = [hsx|
data-policy-scope={widget.policyScope} data-policy-scope={widget.policyScope}
data-widget-version={tshow widget.version} data-widget-version={tshow widget.version}
> >
{renderEnvelopeWarnings warnings}
{inner} {inner}
<div class="ihf-widget-controls mt-2"> <div class="ihf-widget-controls mt-2">
<a href={WidgetAnnotationsAction { widgetId = widget.id }} <a href={WidgetAnnotationsAction { widgetId = widget.id }}
@@ -42,3 +52,32 @@ widgetEnvelope widget inner = [hsx|
</div> </div>
</div> </div>
|] |]
-- | Validate a Widget record against EnvelopeEmissionContract v1.0 required
-- attributes. Returns a list of human-readable warning messages for any
-- attribute that is missing or empty. An empty list means the widget is
-- contract-compliant.
envelopeContractWarnings :: Widget -> [Text]
envelopeContractWarnings widget = catMaybes
[ if isNothing widget.viewContext || widget.viewContext == Just ""
then Just "envelope:v1.0 — data-view-context is missing (set widget.viewContext)"
else Nothing
-- data-widget-id and data-hub-id are always present (non-nullable fields)
]
renderEnvelopeWarnings :: [Text] -> Html
renderEnvelopeWarnings [] = mempty
renderEnvelopeWarnings ws = [hsx|
<div class="bg-amber-50 border border-amber-200 rounded px-3 py-1 mb-1 text-xs text-amber-700">
<strong>Envelope contract warning:</strong>
{forEach ws (\w -> [hsx|<div>{w}</div>|])}
</div>
|]
-- | Status badge colour for WidgetAdapterSpec and contract status values.
adapterStatusBadge :: Text -> Text
adapterStatusBadge "active" = "bg-green-100 text-green-800"
adapterStatusBadge "draft" = "bg-yellow-100 text-yellow-800"
adapterStatusBadge "deprecated" = "bg-gray-100 text-gray-500"
adapterStatusBadge "superseded" = "bg-gray-100 text-gray-400"
adapterStatusBadge _ = "bg-gray-100 text-gray-600"

View File

@@ -0,0 +1,104 @@
module Web.Controller.ApiInteractionEvents where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import qualified Data.Text as T
import Network.Wai (requestMethod, requestHeaders)
-- | Accepted event types per InteractionReportingContract v1.0
apiAcceptedEventTypes :: [Text]
apiAcceptedEventTypes = ["clicked", "viewed", "submitted", "dismissed", "errored"]
instance Controller ApiInteractionEventsController where
action CreateApiInteractionEventAction = do
-- Method guard — only POST accepted.
when (requestMethod ?request /= "POST") do
setStatus 405
respondJson (object ["error" .= ("Method not allowed" :: Text)])
-- Bearer token auth — validate against hub.api_key.
let authHeader = lookup "Authorization" (requestHeaders ?request)
let mApiKey = authHeader >>= \h ->
let t = cs h :: Text
in if "Bearer " `T.isPrefixOf` t
then Just (T.drop 7 t)
else Nothing
case mApiKey of
Nothing -> do
setStatus 401
respondJson (object ["error" .= ("Authorization: Bearer <hub-api-key> required" :: Text)])
Just apiKey -> do
mHub <- query @Hub
|> filterWhere (#apiKey, Just apiKey)
|> fetchOneOrNothing
case mHub of
Nothing -> do
setStatus 401
respondJson (object ["error" .= ("Invalid or unknown API key" :: Text)])
Just hub -> createEventForHub hub
createEventForHub :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Hub -> IO ResponseReceived
createEventForHub hub = do
-- Validate required fields per contract v1.0
widgetIdText <- paramOrNothing @Text "widget_id"
eventType <- paramOrNothing @Text "event_type"
_occurredAt <- paramOrNothing @Text "occurred_at"
let missing = catMaybes
[ if isNothing widgetIdText then Just ("widget_id" :: Text) else Nothing
, if isNothing eventType then Just "event_type" else Nothing
, if isNothing _occurredAt then Just "occurred_at" else Nothing
]
unless (null missing) do
setStatus 422
respondJson (object
[ "error" .= ("Missing required fields" :: Text)
, "missing" .= missing
])
let Just wIdText = widgetIdText
Just evType = eventType
unless (evType `elem` apiAcceptedEventTypes) do
setStatus 422
respondJson (object
[ "error" .= ("Unacceptable event_type" :: Text)
, "accepted" .= apiAcceptedEventTypes
])
-- Resolve widget — must belong to this hub.
case readMay wIdText of
Nothing -> do
setStatus 422
respondJson (object ["error" .= ("widget_id must be a valid UUID" :: Text)])
Just rawId -> do
let wId = Id rawId :: Id Widget
mWidget <- fetchOneOrNothing wId
case mWidget of
Nothing -> do
setStatus 422
respondJson (object ["error" .= ("Widget not found" :: Text)])
Just widget -> do
when (widget.hubId /= hub.id) do
setStatus 403
respondJson (object ["error" .= ("Widget does not belong to this hub" :: Text)])
event <- newRecord @InteractionEvent
|> set #widgetId widget.id
|> set #eventType evType
|> set #actorType "external_adapter"
|> createRecord
setStatus 201
respondJson (object
[ "id" .= event.id
, "widget_id" .= event.widgetId
, "event_type" .= event.eventType
, "occurred_at" .= event.occurredAt
])

View File

@@ -0,0 +1,21 @@
module Web.Controller.EnvelopeEmissionContracts where
import Web.Types
import Web.View.EnvelopeEmissionContracts.Index
import Web.View.EnvelopeEmissionContracts.Show
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller EnvelopeEmissionContractsController where
beforeAction = ensureIsUser
action EnvelopeEmissionContractsAction = do
contracts <- query @EnvelopeEmissionContract
|> orderByDesc #createdAt
|> fetch
render IndexView { contracts }
action ShowEnvelopeEmissionContractAction { envelopeEmissionContractId } = do
contract <- fetch envelopeEmissionContractId
render ShowView { contract }

View File

@@ -0,0 +1,21 @@
module Web.Controller.InteractionReportingContracts where
import Web.Types
import Web.View.InteractionReportingContracts.Index
import Web.View.InteractionReportingContracts.Show
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller InteractionReportingContractsController where
beforeAction = ensureIsUser
action InteractionReportingContractsAction = do
contracts <- query @InteractionReportingContract
|> orderByDesc #createdAt
|> fetch
render IndexView { contracts }
action ShowInteractionReportingContractAction { interactionReportingContractId } = do
contract <- fetch interactionReportingContractId
render ShowView { contract }

View File

@@ -17,6 +17,7 @@ import Web.Controller.Requirements ()
import Web.Controller.DecisionRecords () import Web.Controller.DecisionRecords ()
import Web.Controller.DeploymentRecords () import Web.Controller.DeploymentRecords ()
import Web.Controller.AgentProposals () import Web.Controller.AgentProposals ()
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 ()
@@ -35,6 +36,7 @@ instance FrontController WebApplication where
, parseRoute @DecisionRecordsController , parseRoute @DecisionRecordsController
, parseRoute @DeploymentRecordsController , parseRoute @DeploymentRecordsController
, parseRoute @AgentProposalsController , parseRoute @AgentProposalsController
, parseRoute @ApiInteractionEventsController
, parseRoute @EnvelopeEmissionContractsController , parseRoute @EnvelopeEmissionContractsController
, parseRoute @InteractionReportingContractsController , parseRoute @InteractionReportingContractsController
, parseRoute @WidgetAdapterSpecsController , parseRoute @WidgetAdapterSpecsController

View File

@@ -35,6 +35,19 @@ instance AutoRoute DeploymentRecordsController
instance AutoRoute AgentProposalsController instance AutoRoute AgentProposalsController
-- Phase 6 — Cross-Framework UI Adaptation -- Phase 6 — Cross-Framework UI Adaptation
-- API endpoint: POST /api/v1/interaction-events
instance CanRoute ApiInteractionEventsController where
parseRoute' = do
_ <- string "/api"
_ <- string "/v1"
_ <- string "/interaction-events"
endOfInput
pure CreateApiInteractionEventAction
instance HasPath ApiInteractionEventsController where
pathTo CreateApiInteractionEventAction = "/api/v1/interaction-events"
instance AutoRoute EnvelopeEmissionContractsController instance AutoRoute EnvelopeEmissionContractsController
instance AutoRoute InteractionReportingContractsController instance AutoRoute InteractionReportingContractsController
instance AutoRoute WidgetAdapterSpecsController instance AutoRoute WidgetAdapterSpecsController

View File

@@ -112,6 +112,10 @@ data AgentProposalsController
| RejectProposalAction { agentProposalId :: !(Id AgentProposal) } | RejectProposalAction { agentProposalId :: !(Id AgentProposal) }
deriving (Eq, Show, Data) deriving (Eq, Show, Data)
data ApiInteractionEventsController
= CreateApiInteractionEventAction
deriving (Eq, Show, Data)
data EnvelopeEmissionContractsController data EnvelopeEmissionContractsController
= EnvelopeEmissionContractsAction = EnvelopeEmissionContractsAction
| ShowEnvelopeEmissionContractAction { envelopeEmissionContractId :: !(Id EnvelopeEmissionContract) } | ShowEnvelopeEmissionContractAction { envelopeEmissionContractId :: !(Id EnvelopeEmissionContract) }

View File

@@ -0,0 +1,67 @@
module Web.View.EnvelopeEmissionContracts.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Application.Helper.View (adapterStatusBadge)
data IndexView = IndexView
{ contracts :: ![EnvelopeEmissionContract]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Envelope Emission Contracts</h1>
<p class="text-sm text-gray-500 mt-1">
Formalises which <code>data-*</code> attributes every widget envelope must emit.
</p>
</div>
<a href={InteractionReportingContractsAction}
class="text-sm text-gray-500 hover:text-gray-800">
Reporting Contracts
</a>
</div>
{if null contracts
then [hsx|<p class="text-sm text-gray-400">No contracts found.</p>|]
else renderTable contracts}
|]
renderTable :: [EnvelopeEmissionContract] -> Html
renderTable contracts = [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-600">Version</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Required Attributes</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach contracts renderRow}
</tbody>
</table>
</div>
|]
renderRow :: EnvelopeEmissionContract -> Html
renderRow c = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowEnvelopeEmissionContractAction { envelopeEmissionContractId = c.id }}
class="font-mono text-indigo-600 hover:underline">v{c.contractVersion}</a>
</td>
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{tshow c.requiredAttributes}</td>
<td class="px-4 py-3">
<span class={adapterStatusBadge c.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{c.status}
</span>
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show c.createdAt}</td>
</tr>
|]

View File

@@ -0,0 +1,59 @@
module Web.View.EnvelopeEmissionContracts.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Application.Helper.View (adapterStatusBadge)
data ShowView = ShowView
{ contract :: !EnvelopeEmissionContract
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-6">
<a href={EnvelopeEmissionContractsAction} class="text-sm text-gray-500 hover:text-gray-800">
Envelope Contracts
</a>
</div>
<div class="flex items-center gap-3 mb-6">
<h1 class="text-2xl font-semibold">
Envelope Contract <span class="font-mono">v{contract.contractVersion}</span>
</h1>
<span class={adapterStatusBadge contract.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{contract.status}
</span>
</div>
{forEach (contractDescription contract) (\d -> [hsx|
<p class="text-sm text-gray-600 mb-6">{d}</p>
|])}
<div class="grid grid-cols-1 gap-4">
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Required Attributes</h2>
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.requiredAttributes}</pre>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Optional Attributes</h2>
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.optionalAttributes}</pre>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Validation Rules</h2>
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.validationRules}</pre>
</div>
</div>
<div class="mt-6 text-xs text-gray-400">
Created: {show contract.createdAt}
</div>
|]
contractDescription :: EnvelopeEmissionContract -> [Text]
contractDescription c = case c.description of
Just d -> [d]
Nothing -> []

View File

@@ -0,0 +1,69 @@
module Web.View.InteractionReportingContracts.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Application.Helper.View (adapterStatusBadge)
data IndexView = IndexView
{ contracts :: ![InteractionReportingContract]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Interaction Reporting Contracts</h1>
<p class="text-sm text-gray-500 mt-1">
Defines the REST endpoint and payload schema for external adapter event submission.
</p>
</div>
<a href={EnvelopeEmissionContractsAction}
class="text-sm text-gray-500 hover:text-gray-800">
Envelope Contracts
</a>
</div>
{if null contracts
then [hsx|<p class="text-sm text-gray-400">No contracts found.</p>|]
else renderTable contracts}
|]
renderTable :: [InteractionReportingContract] -> Html
renderTable contracts = [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-600">Version</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Endpoint</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Auth</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach contracts renderRow}
</tbody>
</table>
</div>
|]
renderRow :: InteractionReportingContract -> Html
renderRow c = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowInteractionReportingContractAction { interactionReportingContractId = c.id }}
class="font-mono text-indigo-600 hover:underline">v{c.contractVersion}</a>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-700">{c.endpointPath}</td>
<td class="px-4 py-3 text-xs text-gray-500">{c.authScheme}</td>
<td class="px-4 py-3">
<span class={adapterStatusBadge c.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{c.status}
</span>
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show c.createdAt}</td>
</tr>
|]

View File

@@ -0,0 +1,76 @@
module Web.View.InteractionReportingContracts.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Application.Helper.View (adapterStatusBadge)
data ShowView = ShowView
{ contract :: !InteractionReportingContract
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-6">
<a href={InteractionReportingContractsAction} class="text-sm text-gray-500 hover:text-gray-800">
Reporting Contracts
</a>
</div>
<div class="flex items-center gap-3 mb-6">
<h1 class="text-2xl font-semibold">
Reporting Contract <span class="font-mono">v{contract.contractVersion}</span>
</h1>
<span class={adapterStatusBadge contract.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{contract.status}
</span>
</div>
{forEach (contractDescription contract) (\d -> [hsx|
<p class="text-sm text-gray-600 mb-6">{d}</p>
|])}
<div class="grid grid-cols-1 gap-4">
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Endpoint</h2>
<div class="flex items-center gap-2">
<span class="bg-green-100 text-green-800 text-xs font-bold px-2 py-0.5 rounded">POST</span>
<code class="text-sm text-gray-800">{contract.endpointPath}</code>
</div>
<div class="mt-2 text-xs text-gray-500">Auth: <code>{contract.authScheme}</code></div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Required Fields</h2>
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.requiredFields}</pre>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Accepted Event Types</h2>
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.acceptedEventTypes}</pre>
</div>
</div>
<div class="mt-6 bg-blue-50 border border-blue-200 rounded p-4 text-sm">
<h3 class="font-semibold text-blue-800 mb-2">Example Request</h3>
<pre class="text-xs text-blue-900 overflow-auto">curl -X POST {contract.endpointPath} \
-H "Authorization: Bearer &lt;hub-api-key&gt;" \
-H "Content-Type: application/json" \
-d '{"{"}
"widget_id": "&lt;uuid&gt;",
"hub_id": "&lt;uuid&gt;",
"event_type": "clicked",
"occurred_at": "2026-03-29T12:00:00Z"
{"}"}'</pre>
</div>
<div class="mt-4 text-xs text-gray-400">
Created: {show contract.createdAt}
</div>
|]
contractDescription :: InteractionReportingContract -> [Text]
contractDescription c = case c.description of
Just d -> [d]
Nothing -> []

View File

@@ -62,7 +62,7 @@ Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 6,
```task ```task
id: IHUB-WP-0006-T01 id: IHUB-WP-0006-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7" state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7"
``` ```