feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
Some checks failed
Test / test (push) Has been cancelled

Delivers the full Phase 9 external API layer:

- Versioned REST API (/api/v2/) with OpenAPI 3.1 spec; enum arrays for
  widget_type, event_type, annotation category drawn live from registry tables
- OAuth 2.0 client credentials flow (/api/v2/token); hub:*:write scopes
  gated on active HubCapabilityManifest FK
- API key management: SHA256-hashed tokens, key_prefix for display,
  one-time reveal on creation, revocation support
- TypeScript and Python consumer SDKs generated from registry tables
  (/api/v2/sdk/ihf-client.ts, /api/v2/sdk/ihf-client.py)
- Webhook delivery: HMAC-SHA256 signing, append-only webhook_deliveries,
  fire-and-forget dispatch via forkIO, 3-retry logic
- Admin API dashboard with 24h stats (request count, error rate, last seen)
- Rate limiting (per-minute) and daily quota enforcement via api_request_log
- Schema migration: api_consumers, api_keys, webhook_subscriptions (CHECK
  constraint on 6 framework lifecycle topics), webhook_deliveries
  (append-only trigger), api_request_log
- ARCHITECTURE-LAYERS.md scorecard: 3.34 → 3.41 (approaching Strong)
- contracts/functional/interaction-reporting-v1.md extended with Phase 9
  endpoint catalogue and 422 validation error format

GAAF: no bare TEXT discriminators; webhook event_type uses CHECK constraint
over 6 allowed framework lifecycle topic strings (not widget event types).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 19:52:20 +00:00
parent 286d33923a
commit 3cac021213
38 changed files with 3581 additions and 17 deletions

View File

@@ -0,0 +1,764 @@
---
id: IHUB-WP-0010
type: workplan
title: "IHF Phase 9 — External API Surface and Consumer SDKs"
domain: inter_hub
repo: inter-hub
status: active
owner: custodian
topic_slug: inter_hub
created: "2026-04-01"
updated: "2026-04-01"
state_hub_sync: done
state_hub_workstream_id: "c6c6e87f-e145-4bc4-9881-61f92b14d4de"
---
# IHF Phase 9 — External API Surface and Consumer SDKs
## Goal
Make the IHF consumable by systems outside the reference IHP implementation.
Phase 8 established federated governance within a single deployment. Phase 9
exposes that governance state as a stable, versioned, authenticated REST API
and ships consumer SDKs that make integration a day's work rather than a
project.
## Background
Phases 18 and IHUB-WP-0009 (GAAF Compliance Foundation) are complete. All
Phase 9 entry gates are satisfied:
- Four type registries seeded and validated in controllers ✓
- `HubCapabilityManifest` table and activation workflow operational ✓
- `/contracts/` directory with Core and Functional contract artifacts ✓
- `ARCHITECTURE-LAYERS.md` scorecard at ≥3.3 ✓ (actual: 3.34)
- Architectural fitness functions in CI ✓
- `docs/domain-hub-extension-guide.md` published ✓
The type registries established in WP-0009 are the critical dependency: the
OpenAPI 3.1 spec must enumerate `widget_type`, `event_type`, and `category`
as finite enum arrays derived from those registries — not unconstrained
strings. Building the API without this would produce an incorrect contract.
Reference: `specs/InteractionHubFrameworkSpecification_v0.2.md` §Phase 9.
## GAAF Architectural Constraints
All new code in this workplan must comply with:
1. **No bare TEXT type discriminators**`api_consumers.consumer_type` (if
added) must reference a registry or carry a CHECK constraint.
2. **ApiConsumer must declare capability scope** — if a consumer is a domain
hub, its `hub_capability_manifest_id` FK must be set. Non-hub consumers
leave it NULL; that is not an exception.
3. **Append-only invariant is permanent** — no migration in this workplan may
add UPDATE or DELETE capability to `interaction_events` or `outcome_signals`.
4. **Core tables are frozen**`widgets`, `interaction_events`, `annotations`,
`hubs`, and Phase 14 dependents must not gain columns without a
corresponding `/contracts/core/` update.
## Data Artifacts Introduced
`ApiConsumer`, `ApiKey`, `WebhookSubscription`, `WebhookDelivery`
Schema additions:
- `api_consumers` table with `hub_capability_manifest_id` FK (nullable)
- `api_keys` table linked to `api_consumers`
- `webhook_subscriptions` table
- `webhook_deliveries` table
- `api_request_log` table (for usage dashboard and rate limiting)
---
## Tasks
### T01 — Schema: ApiConsumer, ApiKey, WebhookSubscription, WebhookDelivery
```task
id: IHUB-WP-0010-T01
status: done
priority: high
state_hub_task_id: "4db51957-5aaf-4834-88f8-14c972b32136"
```
Add all new tables required by Phase 9. This is the only task that touches
`Application/Schema.sql` and migrations.
1. Schema additions in `Application/Schema.sql`:
```sql
-- api_consumers: external systems that authenticate against /api/v2/
-- hub_capability_manifest_id: set when the consumer is a domain hub;
-- NULL for third-party tools that authenticate without a manifest.
CREATE TABLE api_consumers (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
hub_capability_manifest_id UUID REFERENCES hub_capability_manifests(id),
rate_limit_per_minute INTEGER NOT NULL DEFAULT 60,
quota_per_day INTEGER NOT NULL DEFAULT 10000,
quota_resets_at TIMESTAMP WITH TIME ZONE NOT NULL
DEFAULT (date_trunc('day', NOW() AT TIME ZONE 'UTC') + interval '1 day'),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
-- api_keys: bearer tokens for consumer authentication
CREATE TABLE api_keys (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
api_consumer_id UUID NOT NULL REFERENCES api_consumers(id) ON DELETE CASCADE,
key_prefix TEXT NOT NULL, -- first 8 chars of the key, for display
key_hash TEXT NOT NULL, -- bcrypt/sha256 hash; never store plain
scopes TEXT NOT NULL DEFAULT '', -- space-separated OAuth-style scopes
expires_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE UNIQUE INDEX api_keys_prefix_idx ON api_keys (key_prefix);
CREATE INDEX api_keys_consumer_idx ON api_keys (api_consumer_id);
-- webhook_subscriptions: consumer event subscriptions
CREATE TABLE webhook_subscriptions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
api_consumer_id UUID NOT NULL REFERENCES api_consumers(id) ON DELETE CASCADE,
event_type TEXT NOT NULL
REFERENCES event_type_registry(name),
target_url TEXT NOT NULL,
secret TEXT NOT NULL, -- HMAC signing secret; never expose in responses
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE INDEX webhook_subs_consumer_idx ON webhook_subscriptions (api_consumer_id);
CREATE INDEX webhook_subs_event_type_idx ON webhook_subscriptions (event_type);
-- webhook_deliveries: delivery attempt log (append-only)
CREATE TABLE webhook_deliveries (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
webhook_subscription_id UUID NOT NULL
REFERENCES webhook_subscriptions(id),
payload JSONB NOT NULL,
attempted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending','delivered','failed')),
response_code INTEGER,
latency_ms INTEGER,
error_message TEXT
);
-- Append-only trigger for webhook_deliveries
CREATE OR REPLACE FUNCTION webhook_deliveries_no_update()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'webhook_deliveries is append-only';
END; $$;
CREATE TRIGGER webhook_deliveries_no_update
BEFORE UPDATE ON webhook_deliveries
FOR EACH ROW EXECUTE FUNCTION webhook_deliveries_no_update();
-- api_request_log: usage tracking for dashboard and rate limiting
CREATE TABLE api_request_log (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
api_consumer_id UUID REFERENCES api_consumers(id),
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER NOT NULL,
latency_ms INTEGER,
requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE INDEX api_request_log_consumer_idx
ON api_request_log (api_consumer_id, requested_at DESC);
```
2. Write migration: `Application/Migration/1743000001-api-phase9-schema.sql`
containing the DDL above. Run via `migrate`.
3. Register new types in `Web/Types.hs`:
- `data ApiConsumer`, `data ApiKey`, `data WebhookSubscription`,
`data WebhookDelivery`, `data ApiRequestLog`
- Include `HasField` instances following IHP conventions.
4. Add routes in `Web/Routes.hs` for:
- `ApiConsumersController` (admin CRUD)
- `ApiKeysController` (admin CRUD)
- `WebhookSubscriptionsController` (admin CRUD)
- `Api.V2.*` controllers (added in T02/T03)
**Exit criteria:** `migrate` runs cleanly; Types.hs compiles; no existing
tests broken.
---
### T02 — REST read API `/api/v2/` for all core IHF artifact types
```task
id: IHUB-WP-0010-T02
status: done
priority: high
state_hub_task_id: "5a4fc7d1-f930-40f9-bbcf-323aaa717191"
```
Expose all core IHF artifact types as read-only JSON endpoints under
`/api/v2/`. All endpoints require a valid `ApiKey` Bearer token.
1. Create `Web/Controller/Api/V2/` directory with a shared auth helper:
```haskell
-- Web/Controller/Api/V2/Auth.hs
module Web.Controller.Api.V2.Auth where
import IHP.Prelude
import Web.Types
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString.Base16 as Base16
-- | Extract and validate Bearer token from Authorization header.
-- Returns the ApiConsumer on success or halts with 401.
requireApiConsumer :: (?context :: ControllerContext) => IO ApiConsumer
```
2. Implement read controllers for each artifact type. Pattern:
```haskell
-- Web/Controller/Api/V2/WidgetsController.hs
instance Controller Api.V2.WidgetsController where
action Api.V2.IndexWidgetsAction = do
consumer <- requireApiConsumer
widgets <- query @Widget
|> orderByDesc #createdAt
|> paginate
|> fetch
renderJson (map widgetToJson widgets)
action Api.V2.ShowWidgetAction { widgetId } = do
consumer <- requireApiConsumer
widget <- fetch widgetId
renderJson (widgetToJson widget)
```
3. Implement controllers for:
- `Api.V2.WidgetsController` (index, show)
- `Api.V2.InteractionEventsController` (index, show; filter by widget_id, hub_id, event_type)
- `Api.V2.AnnotationsController` (index, show; filter by widget_id, category)
- `Api.V2.RequirementCandidatesController` (index, show)
- `Api.V2.DecisionRecordsController` (index, show)
- `Api.V2.DeploymentRecordsController` (index, show)
- `Api.V2.OutcomeSignalsController` (index, show)
4. JSON serialisation: each `*ToJson` function must produce camelCase field
names. Type discriminator fields (`widget_type`, `event_type`, `category`)
emit the registry name string as-is.
5. Pagination: `?page=N&per_page=M` query params; default 50 per page; max 200.
Response envelope: `{ "data": [...], "meta": { "page": N, "per_page": M, "total": T } }`.
6. Authentication failure: 401 JSON `{ "error": "Unauthorized", "code": "invalid_api_key" }`.
**Exit criteria:** All seven read endpoints return 200 JSON with a valid Bearer
token; return 401 without one; pagination meta renders in all responses.
---
### T03 — REST write API: submit interaction events and annotations
```task
id: IHUB-WP-0010-T03
status: done
priority: high
state_hub_task_id: "ade7f62b-b27d-4edf-b91c-b785057d269d"
```
Allow external consumers to submit interaction events and annotations via
`POST /api/v2/interaction-events` and `POST /api/v2/annotations`.
1. Extend `Api.V2.InteractionEventsController` with `CreateInteractionEventAction`:
```
POST /api/v2/interaction-events
Content-Type: application/json
Authorization: Bearer <token>
{
"widgetId": "<uuid>",
"eventType": "clicked",
"viewContext": "dashboard",
"metadata": {}
}
```
2. Validation pipeline for `event_type`:
- Check `event_type` exists in `event_type_registry`. If not → HTTP 422:
```json
{
"error": "Unregistered event type",
"code": "unregistered_event_type",
"value": "<submitted_value>",
"registry": "/api/v2/event-types"
}
```
- If the consumer has a `hub_capability_manifest_id`, additionally check
the type is in `declared_event_types` on the active manifest. If not →
HTTP 422 with `"code": "event_type_not_in_manifest"`.
- Framework-level types (owned by no hub) are always allowed.
3. Extend `Api.V2.AnnotationsController` with `CreateAnnotationAction`:
```
POST /api/v2/annotations
Body: { "widgetId": "<uuid>", "category": "ux-friction", "body": "..." }
```
Validate `category` against `annotation_category_registry` with the same
422 pattern.
4. On success, return HTTP 201 with the created record JSON.
5. Add `GET /api/v2/event-types` and `GET /api/v2/widget-types` and
`GET /api/v2/annotation-categories` — read the respective registry tables
and return the list of registered names with description and owner_hub_id.
These endpoints are unauthenticated (public enumeration).
**Exit criteria:** Valid POST creates a record and returns 201; unregistered
`event_type` returns 422 with registry URL in response; unauthenticated POST
returns 401; registry listing endpoints return 200 without auth.
---
### T04 — OpenAPI 3.1 spec generation with type registry enumerations
```task
id: IHUB-WP-0010-T04
status: done
priority: high
state_hub_task_id: "f5ba93af-6772-4b87-b1b5-0c79b46486c8"
```
Generate a valid OpenAPI 3.1 JSON document at `GET /api/v2/openapi.json` that
accurately describes all Phase 9 endpoints. The critical constraint: type
discriminator fields must carry `enum` arrays, not unconstrained `string`.
1. Create `Web/Controller/Api/V2/OpenApiController.hs`. This controller builds
and renders the OpenAPI document in-process (no code-generation tool needed
for v1; a Haskell value → JSON approach is fine).
2. The spec structure:
```
openapi: "3.1.0"
info:
title: Interaction Hub Framework API
version: "2.0"
description: |
IHF external API. See /contracts/functional/interaction-reporting-v1.md
for the human-readable contract companion.
servers:
- url: /api/v2
paths:
/widgets: ...
/interaction-events: ...
...
components:
schemas:
WidgetType:
type: string
enum: [<values from widget_type_registry>]
EventType:
type: string
enum: [<values from event_type_registry>]
AnnotationCategory:
type: string
enum: [<values from annotation_category_registry>]
```
3. The enum arrays must be **queried live** from the registry tables at request
time. The spec is generated data, not a static file. This ensures the spec
stays in sync with the registries automatically.
4. Add `x-ihf-contract: /contracts/functional/interaction-reporting-v1.md` as
a top-level extension field.
5. Serve at `GET /api/v2/openapi.json` (unauthenticated — it is a public
contract). Also serve `GET /api/v2/openapi.yaml` as a convenience.
6. Add a Swagger UI at `GET /api/v2/docs` that loads the generated spec.
Use the CDN-served Swagger UI (static HTML that loads swagger-ui from
unpkg; no npm build step required).
**Exit criteria:** `/api/v2/openapi.json` is valid OpenAPI 3.1; `widget_type`,
`event_type`, and `category` fields reference `$ref` schemas with `enum`
arrays populated from live registries; a new type added to any registry
appears in the spec on the next request.
---
### T05 — OAuth 2.0 client credentials flow
```task
id: IHUB-WP-0010-T05
status: done
priority: high
state_hub_task_id: "b8c5c9d5-7d9c-42ca-b679-0f7514b61910"
```
Implement the OAuth 2.0 client credentials grant so that external consumers
can obtain short-lived access tokens by presenting their client ID and secret.
1. `POST /api/v2/token` endpoint:
```
Content-Type: application/x-www-form-urlencoded
Body: grant_type=client_credentials&client_id=<id>&client_secret=<secret>&scope=hub:dev-hub:read
```
Response:
```json
{ "access_token": "<opaque>", "token_type": "Bearer", "expires_in": 3600 }
```
2. Implementation approach: opaque tokens stored in `api_keys` (reuse the
existing table; add `token_type TEXT DEFAULT 'bearer'` and `expires_at`).
JWTs are acceptable but opaque tokens are simpler for v1 and avoid key
management complexity.
3. Scope validation:
- `hub:{slug}:read` — allowed for all active consumers
- `hub:{slug}:write` — requires the consumer's `hub_capability_manifest_id`
to reference an active manifest for that hub slug
- `framework:read` — allowed for all
- Unknown scopes → 400 `invalid_scope`
4. Token lifetime: 3600 seconds (1 hour). Refresh tokens are out of scope for
Phase 9.
5. Standard OAuth error responses: 400 with `error` field (`invalid_client`,
`invalid_grant`, `invalid_scope`, `unsupported_grant_type`).
6. The existing ApiKey Bearer auth in T02/T03 should accept both pre-created
static keys AND tokens issued by this endpoint. Both are rows in `api_keys`.
**Exit criteria:** Full client credentials flow works end-to-end; hub-scoped
write is rejected without a manifest; token expiry is enforced.
---
### T06 — API consumer and API key management UI
```task
id: IHUB-WP-0010-T06
status: done
priority: medium
state_hub_task_id: "64f48e86-522a-4566-a91a-ce840d3f2633"
```
Admin-only web UI for managing API consumers and their keys. Extends the
existing admin section pattern established in Phase 8.
1. `Web/Controller/ApiConsumersController.hs`:
- CRUD: index, show, new, create, edit, update, delete
- Delete: soft-delete by setting `is_active = FALSE`; do not destroy records
2. `Web/Controller/ApiKeysController.hs`:
- Index (scoped to a consumer), new, create, revoke (sets `revoked_at`)
- Key generation: create a random 32-byte key, store `key_prefix` (first 8
hex chars) and `key_hash` (SHA-256). Display the full key **once** on
creation — never again. Show a "copy this now" alert banner.
3. Views (`Web/View/ApiConsumers/`, `Web/View/ApiKeys/`):
- Consumer index: name, linked manifest (if set), active/inactive badge,
rate limit, quota, key count
- Consumer show: details + key list + webhook subscriptions
- Consumer form: name, description, manifest selector (optional), rate
limit, quota fields
- Key create success: one-time display of full key value
4. Link "API Consumers" from the existing admin navigation.
5. Domain hub consumer display: if `hub_capability_manifest_id` is set, show
the manifest name, status badge, and a link to its show page.
**Exit criteria:** Full CRUD works; key is shown once on creation; revoked
keys are rejected by auth middleware; consumer linked to manifest shows
manifest details.
---
### T07 — TypeScript SDK generation from type registries
```task
id: IHUB-WP-0010-T07
status: done
priority: medium
state_hub_task_id: "8b7ecc7b-ffbe-449e-a33a-8ac2f029fc9f"
```
Generate a TypeScript client SDK that exports typed enums derived from the
live type registries, plus typed fetch wrappers for `/api/v2/` endpoints.
1. Create `Web/Controller/Api/V2/SdkController.hs` with a
`GenerateTsSdkAction` that:
- Queries `widget_type_registry`, `event_type_registry`,
`annotation_category_registry`
- Renders `Web/View/Api/V2/TsSdk.hs` which produces a `.ts` file
2. The generated TypeScript file (`GET /api/v2/sdk/ihf-client.ts`):
```typescript
// Auto-generated by IHF. Do not edit manually.
// Regenerate by fetching /api/v2/sdk/ihf-client.ts
export enum WidgetType {
Button = "button",
DataTable = "data-table",
// ... all entries from widget_type_registry
}
export enum EventType {
Clicked = "clicked",
Viewed = "viewed",
// ... all entries from event_type_registry
}
export enum AnnotationCategory {
UxFriction = "ux-friction",
// ... all entries from annotation_category_registry
}
export interface IhfApiOptions {
baseUrl: string;
bearerToken: string;
}
export class IhfClient {
constructor(private opts: IhfApiOptions) {}
async getWidgets(params?: { page?: number; perPage?: number }) { ... }
async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) { ... }
async submitInteractionEvent(body: { widgetId: string; eventType: EventType; viewContext: string; metadata?: object }) { ... }
async submitAnnotation(body: { widgetId: string; category: AnnotationCategory; body: string }) { ... }
}
```
3. Serve the raw `.ts` file at `GET /api/v2/sdk/ihf-client.ts` (MIME type
`application/typescript`). This is always regenerated from live registries.
4. Also serve a static SDK download page at `GET /api/v2/sdk` listing
available SDKs with download links and usage instructions.
**Exit criteria:** `/api/v2/sdk/ihf-client.ts` is valid TypeScript; enum
values match the live registries; a new registry entry appears in the SDK on
next request.
---
### T08 — Python SDK generation from type registries
```task
id: IHUB-WP-0010-T08
status: done
priority: medium
state_hub_task_id: "a943d34b-9898-4aa1-a505-9184a6a476b0"
```
Generate a Python client SDK (`GET /api/v2/sdk/ihf-client.py`) following the
same live-generation approach as the TypeScript SDK.
1. Extend `Web/Controller/Api/V2/SdkController.hs` with a
`GeneratePySdkAction`.
2. The generated Python file:
```python
# Auto-generated by IHF. Do not edit manually.
# Regenerate: curl <base>/api/v2/sdk/ihf-client.py > ihf_client.py
from enum import Enum
from typing import Optional
import urllib.request, json
class WidgetType(str, Enum):
BUTTON = "button"
DATA_TABLE = "data-table"
# ... all entries from widget_type_registry
class EventType(str, Enum):
CLICKED = "clicked"
VIEWED = "viewed"
# ... all entries from event_type_registry
class AnnotationCategory(str, Enum):
UX_FRICTION = "ux-friction"
# ... all entries from annotation_category_registry
class IhfClient:
def __init__(self, base_url: str, bearer_token: str): ...
def get_widgets(self, page: int = 1, per_page: int = 50) -> dict: ...
def get_interaction_events(self, widget_id: Optional[str] = None, event_type: Optional[EventType] = None) -> dict: ...
def submit_interaction_event(self, widget_id: str, event_type: EventType, view_context: str, metadata: Optional[dict] = None) -> dict: ...
def submit_annotation(self, widget_id: str, category: AnnotationCategory, body: str) -> dict: ...
```
3. The enum identifier names follow Python conventions (UPPER_SNAKE_CASE).
The values are the registry strings. Use `str, Enum` so instances compare
equal to their string value.
4. Serve at `GET /api/v2/sdk/ihf-client.py` (MIME `text/x-python`).
**Exit criteria:** `/api/v2/sdk/ihf-client.py` is syntactically valid Python;
enum values match live registries; Python SDK page linked from `/api/v2/sdk`.
---
### T09 — Webhook delivery for interaction events, candidate creation, and decision records
```task
id: IHUB-WP-0010-T09
status: done
priority: medium
state_hub_task_id: "249e4de7-a0fb-4325-809b-a896bb807c17"
```
Allow consumers to subscribe to push notifications when governance events occur.
At minimum: `interaction_event.created` and `requirement_candidate.created`.
1. `Web/Controller/WebhookSubscriptionsController.hs`:
- CRUD scoped to an `ApiConsumer`
- `event_type` validated against `event_type_registry`
2. Delivery trigger: after inserting an `InteractionEvent` row, dispatch
delivery for all active subscriptions matching `event_type`. Use IHP's
background job mechanism (`IHP.Job`):
```haskell
-- Web/Job/WebhookDeliveryJob.hs
instance Job WebhookDeliveryJob where
perform WebhookDeliveryJob { .. } = do
-- Build payload, sign with subscription.secret (HMAC-SHA256)
-- POST to target_url
-- Insert WebhookDelivery record with result
```
3. HMAC signing: add `X-IHF-Signature: sha256=<hex>` header to each delivery
request. Consumers verify this header to authenticate the webhook.
4. Retry policy: on HTTP 4xx/5xx or network error, retry up to 3 times with
exponential backoff (5s, 25s, 125s). Record each attempt separately in
`webhook_deliveries`.
5. Add the same delivery trigger for `RequirementCandidate` creation and
`DecisionRecord` creation.
6. Webhook subscriptions management linked from the consumer show page.
**Exit criteria:** A subscription to `interaction_event.created` receives a
POST with HMAC signature when an event is inserted via the API; delivery is
recorded in `webhook_deliveries`; failed deliveries are retried up to 3 times.
---
### T10 — API usage dashboard
```task
id: IHUB-WP-0010-T10
status: done
priority: medium
state_hub_task_id: "fedae9a8-4cdc-41e2-b692-3cc848b9dd6f"
```
Admin view that surfaces per-consumer API usage metrics in near real-time.
1. Request logging middleware: for all `/api/v2/` requests, insert a row into
`api_request_log` after the response is sent (non-blocking). Capture:
`api_consumer_id`, `endpoint`, `method`, `status_code`, `latency_ms`,
`requested_at`.
2. `Web/Controller/ApiDashboardController.hs` with `ShowApiDashboardAction`:
- Aggregate query: per-consumer request count (last 24h), error rate
(4xx+5xx / total), last-seen timestamp, top 5 endpoints
- Uses IHP `AutoRefresh` for push updates on new log rows
3. View (`Web/View/ApiDashboard/Show.hs`):
- Summary table: consumer name, requests (24h), error rate %, last seen,
manifest linked (✓/)
- Time-series sparkline (optional; use a simple count-per-hour query if
rendering inline)
- Error breakdown panel: group by status code
4. Link "API Dashboard" from the admin nav.
**Exit criteria:** Dashboard renders for all consumers; request counts and
error rates update in near-real-time via AutoRefresh; last-seen shows correct
timestamp.
---
### T11 — Rate limiting, quota management, and close-out
```task
id: IHUB-WP-0010-T11
status: done
priority: medium
state_hub_task_id: "32befd50-af80-4bfd-affd-cbcf5e6bfac5"
```
Enforce per-consumer limits and close out the workplan.
**Rate limiting and quota:**
1. Middleware (applied to all `/api/v2/` routes) before action dispatch:
- Count requests in the last 60 seconds from `api_request_log` for the
consumer. If `> rate_limit_per_minute` → HTTP 429 with
`Retry-After: <seconds>` header.
- Count requests today (since `quota_resets_at - 1 day`) for the consumer.
If `> quota_per_day` → HTTP 429 with
`X-Quota-Resets-At: <iso8601>` header.
- Both return JSON `{ "error": "Rate limit exceeded", "code": "rate_limited" }`
or `"code": "quota_exceeded"`.
2. Quota reset: add a daily job `Web/Job/QuotaResetJob.hs` that runs at
00:00 UTC and updates `quota_resets_at` to the next midnight for all
consumers.
**Close-out:**
3. Update `CLAUDE.md`:
- Change `Active Workplan` section to point to the next workplan (Phase 10:
IHUB-WP-0011 when created) or mark Phase 9 as complete.
- Add IHUB-WP-0010 to the "Completed workplans" list.
4. Update `SCOPE.md` to reflect Phase 9 completion.
5. Update `ARCHITECTURE-LAYERS.md`:
- Add `ApiConsumer`, `ApiKey`, `WebhookSubscription`, `WebhookDelivery`,
`ApiRequestLog` to the Extensions layer.
- Update scorecard (Phase 9 adds external API surface — likely improves
Cross-layer and Configuration scores).
- Set next review date.
6. Update `/contracts/functional/interaction-reporting-v1.md` to reference
the live OpenAPI spec URL (`/api/v2/openapi.json`) and confirm the Phase 9
endpoint list.
7. Mark all exit criteria verified in this workplan file.
8. Sync task completions to state hub.
**Exit criteria (Phase 9 complete when all of these are true):**
- [ ] All core IHF artifact types are readable via `/api/v2/`
- [ ] Interaction events and annotations are writable via `/api/v2/`
- [ ] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
`enum` arrays from live registries
- [ ] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
- [ ] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
- [ ] Webhook delivery confirmed for `interaction_event.created` and
`requirement_candidate.created`
- [ ] API usage dashboard renders correctly with AutoRefresh
- [ ] OAuth client credentials flow works end-to-end
- [ ] Submission of an unregistered `event_type` returns HTTP 422 with
registry-referenced error
- [ ] Rate limiting returns 429 with `Retry-After`
- [ ] CLAUDE.md updated; IHUB-WP-0010 listed as complete