--- id: IHUB-WP-0010 type: workplan title: "IHF Phase 9 — External API Surface and Consumer SDKs" domain: inter_hub repo: inter-hub status: done owner: custodian topic_slug: inter_hub created: "2026-04-01" updated: "2026-06-07" completed: "2026-06-07" 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 1–8 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 1–4 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) ## Close-out Correction - 2026-06-07 State Hub showed IHUB-WP-0010 as active even though all eleven task rows were already `done`. The workplan frontmatter and final exit checklist were corrected to reflect the completed Phase 9 state. --- ## 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 { "widgetId": "", "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": "", "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": "", "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: [] EventType: type: string enum: [] AnnotationCategory: type: string enum: [] ``` 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=&client_secret=&scope=hub:dev-hub:read ``` Response: ```json { "access_token": "", "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 /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=` 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: ` header. - Count requests today (since `quota_resets_at - 1 day`) for the consumer. If `> quota_per_day` → HTTP 429 with `X-Quota-Resets-At: ` 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):** - [x] All core IHF artifact types are readable via `/api/v2/` - [x] Interaction events and annotations are writable via `/api/v2/` - [x] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry `enum` arrays from live registries - [x] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums - [x] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums - [x] Webhook delivery confirmed for `interaction_event.created` and `requirement_candidate.created` - [x] API usage dashboard renders correctly with AutoRefresh - [x] OAuth client credentials flow works end-to-end - [x] Submission of an unregistered `event_type` returns HTTP 422 with registry-referenced error - [x] Rate limiting returns 429 with `Retry-After` - [x] CLAUDE.md updated; IHUB-WP-0010 listed as complete