docs: add specification, reference docs, workplan, and agent guidance

Adds all Phase 0 content that was created but never committed:
- CLAUDE.md and SCOPE.md — agent and developer orientation
- specs/TailwindForInteractionHubs_v0.2.md — IHF Tailwind coding guide
- docs/ — five IHP v1.5 reference guides (overview, data, controllers, realtime, ihf-mapping)
- workplans/IHUB-WP-0001 — Phase 1 implementation plan (12 tasks, state-hub synced)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 02:07:13 +01:00
parent 75b88ee760
commit 8b6ce5bbc8
9 changed files with 2181 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
# IHP: Controllers, Views, Forms, and Authentication
---
## Controllers and Actions
Controllers live in `Web/Controller/`. Each action is a constructor in `Web/Types.hs` and a case in the controller's `action` handler.
```haskell
-- Web/Controller/Widgets.hs
instance Controller WidgetsController where
action WidgetsAction = do
widgets <- query @Widget |> fetch
render IndexView { .. }
action ShowWidgetAction { widgetId } = do
widget <- fetch widgetId
events <- query @InteractionEvent
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #occurredAt
|> limit 20
|> fetch
render ShowView { .. }
action CreateWidgetAction = do
let widget = newRecord @Widget
widget
|> fill @'["name", "widgetType", "hubId"]
|> validateField #name nonEmpty
|> ifValid \case
Left widget -> render NewView { widget }
Right widget -> do
widget <- createRecord widget
setSuccessMessage "Widget created"
redirectTo WidgetsAction
```
### Key Patterns
| Pattern | Description |
|---------|-------------|
| `fill @'["fieldA", "fieldB"]` | Type-safe HTTP param binding; compile error if field doesn't exist |
| `validateField #name nonEmpty` | Chains a validator onto the record |
| `ifValid \case Left r -> ... Right r -> ...` | Branches on validation success/failure |
| `createRecord r` | INSERT; returns the persisted record |
| `updateRecord r` | UPDATE |
| `deleteRecord r` | DELETE |
| `redirectTo SomeAction { ... }` | Type-safe redirect |
| `render SomeView { .. }` | Passes in-scope bindings to the view (record wildcard) |
| `respondJson value` | JSON response (no view) |
| `respondHtml someHtml` | Partial HTML response (useful with htmx) |
### Before Filters
```haskell
instance Controller WidgetsController where
beforeAction = do
ensureIsUser -- redirects to login if not authenticated
-- custom per-controller guards here
action ...
```
### ControllerContext
An implicit key-value map threaded through all actions and views. Stores current user, request, flash messages, layout. Access via `fromContext @SomeType` or framework helpers like `currentUser`.
---
## Views and HSX
### The View Typeclass
Views are data types with a `View` instance:
```haskell
data ShowView = ShowView
{ widget :: !Widget
, events :: ![InteractionEvent]
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="widget-detail">
<h1>{widget.name}</h1>
<div class="events">
{forEach events renderEvent}
</div>
</div>
|]
```
Views can also implement a `json` method for content-negotiated API responses.
### HSX Syntax
HSX is a quasi-quoter (`[hsx| ... |]`) that compiles to BlazeHtml. Checked at compile time.
```haskell
[hsx|
-- Interpolation (auto-escaped, XSS-safe)
<span>{widget.name}</span>
-- Raw HTML (opt-out of escaping)
<div>{preEscapedToHtml trustedHtml}</div>
-- Conditionals
{if widget.status == "active"
then [hsx|<span class="badge active">Active</span>|]
else [hsx|<span class="badge inactive">Inactive</span>|]}
-- Loops
<ul>{forEach events renderEvent}</ul>
-- Boolean attributes: True → present, False → omitted
<input disabled={isReadOnly} />
-- Maybe attributes: Just → present, Nothing → omitted
<a target={maybeTarget}>Link</a>
-- Multiple root elements are OK (unlike JSX)
<dt>Name</dt>
<dd>{widget.name}</dd>
|]
```
**`<script>` and `<style>` blocks:** curly braces inside are **literal** (not interpolated). Pass values via `data-` attributes instead.
**Tag variants:**
- `hsx` — strict whitelist of known HTML tags/attributes
- `uncheckedHsx` — skips tag/attribute validation (for web components)
- `customHsx` — extends the whitelist
### Layouts
Layouts have type `Layout = Html -> Html`. They wrap the view's `html` output:
```haskell
-- Web/View/Layout.hs
defaultLayout :: Layout
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
<head>
<title>IHF</title>
{autoRefreshMeta}
<script src="/vendor/morphdom.js"></script>
<script src="/vendor/ihp-auto-refresh.js"></script>
</head>
<body>
<nav>...</nav>
<main>{inner}</main>
</body>
</html>
|]
```
Set per-controller in `beforeAction` or globally in `FrontController.initContext`:
```haskell
setLayout defaultLayout
```
Data shared between layout and views (e.g., current user for the nav) is passed via `putContext`/`fromFrozenContext`.
---
## Forms and Validation
### Building Forms
`formFor` binds a record to a form, auto-generating method and action:
```haskell
renderForm :: Widget -> Html
renderForm widget = formFor widget [hsx|
{textField #name}
{selectField #widgetType widgetTypeOptions}
{selectField #hubId hubOptions}
{textareaField #description}
{submitButton}
|]
```
IHP detects whether the record is new (POST to `/CreateWidget`) or persisted (POST to `/UpdateWidget`) automatically.
**Available field helpers:** `textField`, `emailField`, `passwordField`, `urlField`, `numberField`, `colorField`, `dateField`, `dateTimeField`, `textareaField`, `selectField`, `radioField`, `checkboxField`, `fileField`, `hiddenField`.
**Field customisation options:** `helpText`, `fieldLabel`, `placeholder`, `required`, `disabled`, `autofocus`, `fieldClass`, `labelClass`, `additionalAttributes`, `disableLabel`, `disableGroup`, `disableValidationResult`.
**Custom forms:** `formForWithOptions` — override `formAction`, `formClass`, form ID, HTTP method, disable AJAX submission.
**AJAX submission:** forms submit via AJAX + TurboLinks by default (no full page reload). Opt out with `disableJavascriptSubmission = True`.
### Validation
```haskell
annotation
|> validateField #body nonEmpty
|> validateField #category (`elem` validCategories)
|> ifValid \case
Left invalid -> render NewView { annotation = invalid }
Right valid -> do
createRecord valid
redirectTo WidgetAnnotationsAction { widgetId = valid.widgetId }
```
**Built-in validators:** `nonEmpty`, `isEmail`, `isPhoneNumber`, `isInRange (min, max)`.
**Custom validators:** return `Success` or `Failure "message"`. For DB-aware validators, use `validateFieldIO`.
**How errors attach:** `validateField` stores failures in `record.meta.annotations` (a `[(fieldName, errorMessage)]` list). When `formFor` renders a field with a failure, it auto-adds `is-invalid` CSS class and renders the error message.
**Manual attachment:** `|> attachFailure #field "Custom message"`.
---
## Authentication
IHP provides built-in session-based authentication using salted password hashing (`pwstore-fast`). Accounts are locked for 1 hour after 10 failed attempts.
### Database Requirements
The `users` table must include: `id`, `email`, `password_hash`, `locked_at`, `failed_login_attempts`.
### Setup
```haskell
-- Web/Types.hs
import IHP.LoginSupport.Types
type CurrentUserRecord = User
instance HasNewSessionUrl User where
newSessionUrl _ = "/NewSession"
-- Web/FrontController.hs
instance InitControllerContext WebApplication where
initContext = do
initAuthentication @User
```
Mount `parseRoute @SessionsController` in routes. The framework handles `NewSessionAction`, `CreateSessionAction`, `DeleteSessionAction`.
### Accessing the Current User
```haskell
currentUser -- User; redirects to login if absent
currentUserOrNothing -- Maybe User
currentUserId -- convenience shortcut
```
### Hooks
`SessionsControllerConfig` instance supports `beforeLogin` callback for login history, account status checks, and external identity validation (relevant for IHF's multi-tenant actor attribution).
### Password Handling
```haskell
-- On registration:
user <- newRecord @User
|> fill @'["email", "passwordHash"]
|> set #passwordHash (hashPassword plaintext)
|> createRecord
-- On update (preserve hash when blank):
updatedUser <- user
|> fill @'["email"]
|> ifPasswordChanged (set #passwordHash . hashPassword)
|> updateRecord
```
### Actor Attribution in IHF
For IHF's interaction capture, the `actor_id` and `actor_type` on `InteractionEvent` and `Annotation` should be populated from `currentUserOrNothing` in controllers. For anonymous/low-trust actors, `actor_type = "anonymous"` with a session token as `actor_id`.

View File

@@ -0,0 +1,224 @@
# IHP: Data Modeling and Queries
> How IHP handles schema definition, type generation, querying, relationships, and migrations.
---
## Schema.sql — Single Source of Truth
All models originate in `Application/Schema.sql`. IHP parses this file and **auto-generates Haskell record types** on every save — no manual codegen step.
```sql
CREATE TABLE widgets (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
widget_type TEXT NOT NULL,
hub_id UUID NOT NULL,
capability_ref TEXT,
policy_scope TEXT,
status TEXT NOT NULL DEFAULT 'active',
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
```
This produces a Haskell type roughly equivalent to:
```haskell
data Widget = Widget
{ id :: !(Id Widget)
, name :: !Text
, widgetType :: !Text
, hubId :: !(Id Hub)
, capabilityRef :: !(Maybe Text)
, policyScope :: !(Maybe Text)
, status :: !Text
, version :: !Int
, createdAt :: !UTCTime
}
```
### Conventions
| SQL | Haskell |
|-----|---------|
| `snake_case` column | `camelCase` field |
| `NOT NULL` | plain type |
| nullable | `Maybe` |
| UUID primary key | `Id Widget` (newtype wrapping UUID) |
| Foreign key `hub_id UUID` | `hubId :: !(Id Hub)` |
The `Id Widget` newtype prevents `Id Hub` from being passed where `Id Widget` is expected — foreign key mixups are compile errors.
### Supported PostgreSQL Types
UUID, Text, Int, Integer, Double, Bool, TIMESTAMP WITH TIME ZONE, DATE, JSONB (`Value` via Aeson), ARRAY types, custom ENUMs, INET, Point, and 30+ others — all auto-marshaled to Haskell equivalents.
---
## Schema Designer
A GUI schema editor at `http://localhost:8001/Tables` during development. All operations modify the AST of `Schema.sql` and write back to the file. Editing `Schema.sql` directly in a code editor works equally well.
---
## Query Builder (v1.5: built on `hasql`)
Type-safe fluent API. All queries require an implicit `?modelContext :: ModelContext` — the compiler tracks which code touches the DB.
```haskell
-- Fetch all
widgets <- query @Widget |> fetch
-- Filter + order + limit
recentEvents <- query @InteractionEvent
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #occurredAt
|> limit 50
|> fetch
-- Fetch single (throws RecordNotFoundException on missing)
widget <- fetch widgetId
-- Fetch maybe (returns Nothing on missing)
mWidget <- fetchMaybe widgetId
-- Count
n <- query @Annotation
|> filterWhere (#widgetId, widgetId)
|> fetchCount
```
### Pipeline Mode (v1.5)
Sends multiple queries in a single network round-trip:
```haskell
(widgets, events) <- fetchPipelined do
widgets <- query @Widget |> fetch
events <- query @InteractionEvent |> fetch
pure (widgets, events)
```
### Typed SQL Quasiquoter (v1.5)
Connects to the dev DB at compile time to verify table names, column names, and types:
```haskell
result <- [typedSql|
SELECT w.id, w.name, COUNT(e.id) as event_count
FROM widgets w
LEFT JOIN interaction_events e ON e.widget_id = w.id
WHERE w.hub_id = ${hubId}
GROUP BY w.id, w.name
|]
```
Type-checks column references, parameter types, and return shape. Compile error on typo or schema mismatch.
---
## Relationships
### Has-Many
```haskell
-- User with their widgets
user <- fetch userId >>= fetchRelated #widgets
-- user.widgets :: [Widget]
```
Uses two queries: one for parent, one `WHERE id IN (...)` for children (no N+1).
**With ordering:**
```haskell
user <- fetch userId
>>= pure . modify #widgets (orderByDesc #createdAt)
>>= fetchRelated #widgets
```
### Belongs-To
```haskell
event <- fetch eventId >>= fetchRelated #widgetId
-- event.widgetId :: Widget (resolved from Id Widget to Widget)
```
### Many-to-Many
Use `innerJoin`, `innerJoinThirdTable`, `labelResults` from the query builder.
### Cascade Deletes
Configure in `Schema.sql`:
```sql
ALTER TABLE interaction_events
ADD CONSTRAINT interaction_events_widget_id_fkey
FOREIGN KEY (widget_id) REFERENCES widgets(id) ON DELETE CASCADE;
```
---
## Migrations
Migration files are plain SQL in `Application/Migration/<timestamp>-description.sql`.
### Generating
```
# Via Code Generator web UI at localhost:8001/Generators
# Or CLI:
new-migration "add widget envelope fields"
```
### Running
```bash
migrate # run all pending migrations
DATABASE_URL=postgres://... migrate # against a specific DB
```
Executed migrations are tracked in `schema_migrations`.
### Development Workflow
1. Use Schema Designer to iterate on `Schema.sql` (visual, live)
2. Once stable, copy the relevant DDL into a new migration file
3. Migration files are what run in production and CI
### Example Migration
```sql
-- Application/Migration/1711500000-add-interaction-events.sql
CREATE TABLE interaction_events (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_id UUID,
actor_type TEXT NOT NULL DEFAULT 'user',
view_context JSONB DEFAULT '{}' NOT NULL,
occurred_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX interaction_events_widget_id_idx ON interaction_events (widget_id);
CREATE INDEX interaction_events_occurred_at_idx ON interaction_events (occurred_at DESC);
```
---
## IHF Schema Notes
The IHF Phase 1 data artifacts map naturally to `Schema.sql` tables:
| IHF Artifact | Suggested Table | Key Fields |
|-------------|----------------|-----------|
| Widget | `widgets` | `id`, `name`, `widget_type`, `hub_id`, `capability_ref`, `policy_scope`, `status`, `version` |
| WidgetVersion | `widget_versions` | `widget_id`, `version`, `schema_snapshot` (JSONB), `created_at` |
| Hub | `hubs` | `id`, `slug`, `name`, `domain` |
| CapabilityReference | `capability_references` | `id`, `hub_id`, `capability_key`, `description` |
| ViewContext | `view_contexts` | `id`, `widget_id`, `context_path`, `metadata` (JSONB) |
| InteractionEvent | `interaction_events` | `widget_id`, `event_type`, `actor_id`, `actor_type`, `view_context_id`, `occurred_at` |
| Annotation | `annotations` | `widget_id`, `body`, `category`, `actor_id`, `actor_type`, `widget_state_ref`, `created_at` |
All tables should use UUID primary keys (`uuid_generate_v4()`), `NOT NULL` on required fields, and `TIMESTAMP WITH TIME ZONE` for timestamps (never plain `TIMESTAMP`).

207
docs/ihp-ihf-mapping.md Normal file
View File

@@ -0,0 +1,207 @@
# IHP ↔ IHF Capability Mapping
> How IHP's specific capabilities serve the Interaction Hub Framework's requirements.
> Use this as a decision guide when implementing IHF modules.
---
## Core Mapping
| IHF Requirement | IHP Capability | Notes |
|----------------|---------------|-------|
| Widget semantic identity (stable IDs) | `Id Widget` newtype, `Schema.sql` | UUIDs prevent FK mixups at compile time |
| Widget registry CRUD | Standard IHP controller + AutoRoute | Code generator scaffolds it in minutes |
| Widget envelope metadata | `JSONB` columns + Haskell `Value` | `config`, `metadata`, `context_ref` fields |
| Interaction event capture (append-only) | Controller action + `createRecord` | Add DB-level APPEND-only trigger for enforcement |
| Annotation threads | Belongs-to relationships + `fetchRelated` | `annotations.parent_id` for threading |
| Live dashboard (hub-level signals) | AutoRefresh | Zero client-side framework needed |
| Reactive annotation UI | Server-Side Components or HTMX | SSC for rich state; HTMX for simple append |
| Multi-tenant widget data isolation | DataSync + PostgreSQL RLS | `ihp_user_id()` in RLS policies |
| Governance ledger (decision records) | Append-only table + HTMX | `decisions` table; controllers append, never update |
| Actor attribution | `currentUserOrNothing` + `actor_type` field | Supports human/agent/automation attribution |
| Traceability chain | FK relationships across tables | Widget → InteractionEvent → Annotation → RequirementCandidate |
| Async processing (batch analysis) | IHP Background Jobs | `RunJobs` binary; jobs queued in Postgres |
| Reproducible deployment | NixOS + `deploy-to-nixos` | All server config version-controlled |
| AI-assisted distillation (Phase 5) | Background Jobs + external API calls | Job fetches annotation cluster, calls AI API, stores `AgentProposal` |
---
## Type Safety as Governance Infrastructure
IHP's type system is more than a developer convenience — it is governance infrastructure for IHF:
**Widget identity integrity:** `Id Widget``Id Annotation``Id Hub`. Cross-type ID confusion (a common source of traceability chain breakage) is a compile error, not a runtime bug.
**Field existence enforcement:** `fill @'["widgetType", "hubId"]` lists the fields bound from HTTP parameters. Adding or removing a field in `Schema.sql` propagates as a compile error to every controller that uses it — schema drift is caught immediately.
**URL correctness:** `redirectTo ShowWidgetAction { widgetId = w.id }` — if `ShowWidgetAction` is renamed or its fields change, every call site fails to compile. Broken governance links are impossible.
**View exhaustiveness:** `case` on widget status or annotation category in views will produce a GHC warning if a new constructor is added to the enum — ensuring governance views stay current with the data model.
---
## Schema Design Recommendations for IHF Phase 1
```sql
-- Hubs: bounded domains of responsibility
CREATE TABLE hubs (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
domain TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Widget registry
CREATE TABLE widgets (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
hub_id UUID NOT NULL REFERENCES hubs(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
widget_type TEXT NOT NULL, -- chart | form | table | action | panel | etc.
capability_ref TEXT, -- reference to hub capability
view_context TEXT, -- logical location in the UI
policy_scope TEXT NOT NULL DEFAULT 'internal',
status TEXT NOT NULL DEFAULT 'active',
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Widget version history
CREATE TABLE widget_versions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
schema_snapshot JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
UNIQUE (widget_id, version)
);
-- Interaction events (append-only; never UPDATE or DELETE)
CREATE TABLE interaction_events (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, -- viewed | clicked | submitted | commented | flagged_confusing | etc.
actor_id UUID,
actor_type TEXT NOT NULL DEFAULT 'user', -- user | agent | automation | anonymous
view_context_ref TEXT,
metadata JSONB DEFAULT '{}' NOT NULL,
occurred_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
-- Annotations (structured comments on widgets)
CREATE TABLE annotations (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE,
parent_id UUID REFERENCES annotations(id) ON DELETE CASCADE, -- for threads
body TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'friction', -- friction | defect | wish | policy_concern | doc_gap | trust | other
actor_id UUID,
actor_type TEXT NOT NULL DEFAULT 'user',
widget_state_ref TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
```
---
## AutoRefresh for Hub Dashboards
The hub operator dashboard is the primary immediate value of IHF Phase 1. AutoRefresh delivers it with minimal complexity:
```haskell
-- Web/Controller/Hubs.hs
action ShowHubAction { hubId } = autoRefresh do
hub <- fetch hubId
widgets <- query @Widget
|> filterWhere (#hubId, hubId)
|> orderByDesc #createdAt
|> fetch
recentEvents <- query @InteractionEvent
|> filterWhere (#hubId, hubId)
|> orderByDesc #occurredAt
|> limit 50
|> fetch
annotations <- query @Annotation
|> filterWhere (#hubId, hubId)
|> orderByDesc #createdAt
|> limit 20
|> fetch
render ShowView { .. }
```
Any insert into `widgets`, `interaction_events`, or `annotations` with this `hub_id` automatically re-renders the dashboard for all connected operators. No WebSocket plumbing needed on the server side beyond the `autoRefresh` wrapper.
---
## HTMX for the Governance Ledger
The governance ledger should be append-only. HTMX's hypermedia pattern maps directly:
```haskell
-- Append a decision record
action CreateDecisionAction { requirementId } = do
let decision = newRecord @Decision
decision
|> fill @'["outcome", "rationale"]
|> validateField #outcome nonEmpty
|> validateField #rationale nonEmpty
|> ifValid \case
Left _ -> respondHtml errorFragment
Right decision -> do
decision <- createRecord decision
-- Also update requirement status
requirement <- fetch requirementId
>>= updateRecord . set #status "decided"
respondHtml (renderDecisionCard decision)
```
The client receives a rendered `<div class="decision-card">` fragment and appends it to the ledger list. No full page reload; no separate API contract; the server is the single source of truth.
---
## Background Jobs for Async Distillation (Future — Phase 5)
When IHF Phase 5 (Agent-Assisted Distillation) is implemented, IHP's job system handles the async pipeline:
```haskell
-- Application/Job/DistillAnnotationsJob.hs
instance Job DistillAnnotationsJob where
perform DistillAnnotationsJob { widgetId } = do
annotations <- query @Annotation
|> filterWhere (#widgetId, widgetId)
|> filterWhere (#requirementId, Nothing) -- unprocessed
|> fetch
when (length annotations >= 3) do
-- Call AI API for clustering + proposal draft
proposal <- callAIDistillation annotations
createRecord proposal
-- Trigger AutoRefresh on governance views
notifyTable "requirement_candidates"
```
Jobs are queued as Postgres records and processed by the `RunJobs` binary, which runs alongside the main app in production.
---
## Deployment for IHF
IHF infrastructure can be fully declared in `Config/nix/hosts/` and deployed with `deploy-to-nixos`. A minimal `configuration.nix` for Phase 1:
```nix
{ config, pkgs, ... }: {
services.ihp = {
enable = true;
domain = "ihf.yourdomain.com";
ihpPackage = (import ./ihf.nix).ihf;
dbName = "ihf_production";
sessionSecret = config.age.secrets.ihpSessionSecret.path;
};
services.nginx.enable = true;
security.acme.defaults.email = "admin@yourdomain.com";
security.acme.acceptTerms = true;
}
```
All secrets (session key, DB password) managed via `agenix` — encrypted in git, decrypted on the NixOS host at deploy time.

243
docs/ihp-overview.md Normal file
View File

@@ -0,0 +1,243 @@
# IHP Framework Overview
> Reference notes for implementing the Interaction Hub Framework (IHF) using IHP.
> Based on IHP v1.5.0 (released March 2026).
---
## What IHP Is
**IHP** (Integrated Haskell Platform) is a batteries-included, full-stack web framework built on Haskell and Nix. Its goal is rapid application development with robust, type-safe code.
- **Language:** Haskell (GHC 9.10 default; GHC 9.12 experimental in v1.5)
- **Paradigm:** Functional, strongly typed, server-rendered with optional realtime
- **Creator:** [digitally induced](https://github.com/digitallyinduced) (Hamburg). Open-sourced 2020, in production since 2017
- **Current version:** v1.5.0 (March 25, 2026) — largest release to date (1,051 commits)
- **License:** MIT
### v1.5 Headline Changes
| Change | Impact |
|--------|--------|
| `postgresql-simple``hasql` driver | Up to 50% lower query latency |
| Dev server RAM: 4 GB → 500800 MB | Practical on smaller machines |
| Session middleware 3×, URL gen 5×, rendering 2× faster | Overall snappier |
| `typedSql` quasiquoter | Compile-time SQL type checking against live dev DB |
| `fetchPipelined` | Multiple queries in one network round-trip |
| Composite primary key support | Needed for join-table models |
| Integration test mode | Temporary Postgres DB per test run |
| 15+ modules on Hackage separately | `ihp-mail`, `ihp-datasync`, etc. |
### Design Philosophy
- Type errors at compile time, not runtime
- Single command (`devenv up`) starts a fully self-contained environment — Postgres included, managed by Nix. No Docker, no Kubernetes required
- Optimized for AI-assisted development — the type system automatically verifies generated code
---
## Core Architecture
### MVC-Influenced Structure
| Layer | IHP Location | Role |
|-------|-------------|------|
| Model | `Application/Schema.sql` + generated types | Schema, query builder, relationships |
| Controller | `Web/Controller/<Name>.hs` | Action handlers, parameter binding, DB calls |
| View | `Web/View/<Name>/<Action>.hs` | HSX templates, `View` typeclass |
| Routing | `Web/Routes.hs` + `Web/FrontController.hs` | URL ↔ action mapping |
| Types | `Web/Types.hs` | All controller action constructors |
| Helpers | `Application/Helper/Controller.hs`, `Application/Helper/View.hs` | Shared logic |
Multi-application support: a single project can contain multiple sub-apps (`Web/`, `Admin/`). `new-application admin` generates an `Admin/` subtree with routes auto-prefixed `/admin/`.
### Type-Safe URL / Action System
The IHP router always maps HTTP requests to **data constructors** defined in `Web/Types.hs`:
```haskell
data WidgetsController
= WidgetsAction
| NewWidgetAction
| ShowWidgetAction { widgetId :: !(Id Widget) }
| CreateWidgetAction
| EditWidgetAction { widgetId :: !(Id Widget) }
| UpdateWidgetAction { widgetId :: !(Id Widget) }
| DeleteWidgetAction { widgetId :: !(Id Widget) }
deriving (Eq, Show, Data)
```
- URLs generated from values, not strings: `pathTo ShowWidgetAction { widgetId = someId }`
- Compile-time guarantee: broken links are type errors, not 404s
- `urlTo` generates absolute URLs (protocol + domain)
### Routing
Defined in `Web/Routes.hs`, registered in `Web/FrontController.hs`.
**AutoRoute** (most common): `instance AutoRoute WidgetsController` — IHP generates RESTful routes from action name prefixes:
| Prefix | HTTP Method |
|--------|------------|
| `Delete` | DELETE |
| `Create`, `Update` | POST/PATCH |
| anything else | GET |
**Custom routes:** implement `CanRoute` (attoparsec parser URL → action) and `HasPath` (reverse). `customRoutes` overrides individual AutoRoute entries. Supports SEO slugs like `/widgets/my-slug`.
HTTP method override for HTML forms: pass `_method=DELETE` (or `PATCH`) as a hidden field.
---
## Development Workflow
### Installation
```bash
# 1. Install Determinate Nix (with Flakes + lazy-trees)
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
# 2. Install ihp-new
nix profile install nixpkgs#ihp-new
# 3. Install direnv and hook into shell
nix profile add nixpkgs#direnv
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc # or zshrc
```
### Creating a Project
```bash
ihp-new my-project
cd my-project
devenv up
```
First startup: 1015 minutes (downloads GHC, Postgres, all Haskell deps via Nix binary cache). Subsequent starts are fast (under 30s).
### Dev Server
`devenv up` starts everything:
- Application server on `http://localhost:8000`
- IHP IDE + Schema Designer on `http://localhost:8001`
- Postgres (managed by Nix; no system Postgres needed)
- Live reloading (typically sub-50ms after save)
### Project File Structure
```
my-project/
├── Application/
│ ├── Schema.sql # Single source of truth for all DB types
│ ├── Migration/ # <timestamp>-description.sql files
│ ├── Helper/
│ │ ├── Controller.hs # Shared controller helpers
│ │ └── View.hs # Shared view helpers
│ └── Script/ # One-off scripts / cron job binaries
├── Web/
│ ├── Types.hs # ALL controller action constructors
│ ├── Routes.hs # AutoRoute instance declarations
│ ├── FrontController.hs # WAI entry; dispatch; auth init; default layout
│ ├── Controller/ # One file per controller
│ ├── View/ # One dir per controller, one file per action
│ │ └── Layout.hs # Default layout (Html -> Html)
│ └── Component/ # Server-Side Components (optional)
├── Config/
│ ├── Config.hs # Env vars, secrets, feature flags
│ └── nix/
│ └── hosts/
│ └── production/ # Declarative NixOS server config
├── Test/ # Integration tests
├── static/ # CSS, JS, images
├── flake.nix # Nix flake — all deps declared here
├── App.cabal # Cabal package definition
├── Main.hs # Entry point
└── Makefile # Build targets
```
### Adding Dependencies
In `flake.nix`:
```nix
ihp = {
enable = true;
projectPath = ./.;
packages = [ pkgs.imagemagick ]; # native deps
haskellPackages = p: [
p.ihp
p.aeson
p.your-library
];
};
```
### Code Generators
Navigate to `http://localhost:8001/Generators` or right-click a table in Schema Designer → "Generate Controller". Scaffolds controllers, views, routes, and type entries to match the table's fields. Also available: Migration, Job, Mail, Script generators.
### Testing
v1.5: integration test mode creates a temporary Postgres DB automatically per test run. Tests live in `Test/`.
---
## Deployment
### Primary: NixOS / `deploy-to-nixos`
The entire server config (nginx, Let's Encrypt, systemd, app config) lives declaratively in `Config/nix/hosts/production/` — version-controlled and reproducible.
```bash
deploy-to-nixos production
```
Runs `nixos-rebuild` remotely over SSH. AWS EC2: NixOS AMI, `t3a.small` min (`t3a.medium` recommended), 60 GiB root disk, ports 22/80/443.
**Required env vars:**
| Variable | Purpose |
|----------|---------|
| `IHP_SESSION_SECRET` | Session encryption key (`new-session-secret` to generate) |
| `DATABASE_URL` | Postgres connection string |
| `IHP_BASEURL` | External URL (e.g., `https://example.com`) |
**Production features built-in:**
- Systemd watchdog (heartbeat 30s; auto-restart after 60s)
- Socket activation (queues requests during restarts — zero-downtime deploys)
- Sentry integration via `ihp-sentry` (IHP Pro)
### Docker (IHP Pro)
```bash
nix build .#unoptimized-docker-image --option sandbox false
cat result | podman load
```
Env vars: same as above + `IHP_ASSET_VERSION` (cache-busting) + `IHP_REQUEST_LOGGER_IP_ADDR_SOURCE=FromHeader` (behind load balancer). Minimal Docker images lack CA certificates — copy `caCertificates` and set `SSL_CERT_FILE`.
### Bare Metal Binary
```bash
nix build .#optimized-prod-server # full optimisation
nix build .#unoptimized-prod-server # faster build, for staging
```
Runtime: `IHP_ENV=Production`, `DATABASE_URL`, `PORT` (default 8000). GHC RTS tunable via `GHCRTS`.
### CSS/JS Bundling
```bash
make static/prod.js static/prod.css
```
Dev uses individual files; production uses bundled/minified versions. Background jobs require deploying the `RunJobs` binary alongside the main app.
---
## Key Links
- [IHP Homepage](https://ihp.digitallyinduced.com/)
- [IHP Guide (full docs)](https://ihp.digitallyinduced.com/Guide/)
- [GitHub: digitallyinduced/ihp](https://github.com/digitallyinduced/ihp)
- [ihp-boilerplate](https://github.com/digitallyinduced/ihp-boilerplate) — template used by `ihp-new`
- [v1.5 release announcement](https://discourse.haskell.org/t/ihp-v1-5-has-been-released/13846)

266
docs/ihp-realtime.md Normal file
View File

@@ -0,0 +1,266 @@
# IHP: Realtime Capabilities
IHP offers four distinct realtime mechanisms. Choosing the right one for a given IHF surface is important — they have very different trade-offs.
---
## Summary Comparison
| Mechanism | Best For | Client Requirement | State Lives |
|-----------|---------|-------------------|-------------|
| AutoRefresh | Read-heavy live dashboards | Vanilla JS (morphdom) | Server / DB |
| DataSync | Reactive JS components embedded in server pages | JS SDK + bundler | DB (via RLS) |
| Server-Side Components | Rich interactive UI with Haskell state machine | Vanilla JS | Server (Haskell) |
| HTMX | Partial page updates triggered by user actions | htmx.js | Server |
---
## 1. AutoRefresh
Enables server-side views to **automatically re-render** when the underlying database changes. No client-side framework or state management required.
### How It Works
1. Wrap an action with `autoRefresh do ...`
2. IHP tracks which DB tables the action's queries touched (via PostgreSQL LISTEN/NOTIFY)
3. A WebSocket connection is established when the page loads
4. On any INSERT/UPDATE/DELETE to tracked tables, the server re-runs the action
5. If the generated HTML differs, the delta is sent over WebSocket; `morphdom` applies it to the DOM with minimal DOM mutations
```haskell
action HubDashboardAction { hubId } = autoRefresh do
hub <- fetch hubId
widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch
recentEvents <- query @InteractionEvent
|> filterWhere (#hubId, hubId)
|> orderByDesc #occurredAt
|> limit 20
|> fetch
render HubDashboardView { .. }
```
### Layout Requirements
```haskell
defaultLayout inner = [hsx|
<head>
{autoRefreshMeta}
<script src="/vendor/morphdom.js"></script>
<script src="/vendor/ihp-auto-refresh.js"></script>
</head>
...
|]
```
### Custom SQL Tracking
For `sqlQuery` calls that bypass the query builder, manually register table reads:
```haskell
trackTableRead "interaction_events"
```
### IHF Use Cases
- **Hub operator dashboard** — live widget counts, recent interaction signals, annotation feed
- **Governance ledger view** — live requirement candidate list
- **Triage board** — live annotation queue
AutoRefresh is the right choice for all of these: server-rendered, no JS complexity, updates arrive automatically.
---
## 2. IHP DataSync
A WebSocket-based API that allows **JavaScript to query the database directly**, mirroring the Haskell query builder in JS. For reactive JS components embedded in otherwise server-rendered pages.
### JavaScript API
```javascript
// One-time fetch
const annotations = await query('annotations')
.where('widget_id', widgetId)
.orderBy('created_at', 'DESC')
.fetch();
// Realtime subscription (React hook)
const { records: annotations } = useQuery(
query('annotations')
.where('widget_id', widgetId)
.orderBy('created_at', 'DESC')
);
// Mutations
await createRecord('annotations', { widgetId, body, category });
await updateRecord('annotations', id, { body });
await deleteRecord('annotations', id);
```
When `useQuery()` establishes a subscription, all connected clients automatically re-render when the query result changes.
### Security: Row Level Security
DataSync relies on **PostgreSQL Row Level Security (RLS)**. IHP runs with two DB roles:
- Privileged owner role — used by Haskell server code
- `ihp_authenticated` — used by DataSync (what JS clients see)
Example RLS policy:
```sql
-- Users can only see their own annotations
CREATE POLICY "Users can view own annotations" ON annotations
FOR SELECT
USING (actor_id = ihp_user_id());
-- Anyone can view annotations on public widgets
CREATE POLICY "Public widget annotations are readable" ON annotations
FOR SELECT
USING (EXISTS (
SELECT 1 FROM widgets w
WHERE w.id = annotations.widget_id
AND w.policy_scope = 'public'
));
```
### Setup Requirements
Node.js/npm, a frontend bundler (esbuild recommended), DataSync JS SDK, WebSocket + REST API controllers enabled in `FrontController`.
### IHF Use Cases
- **Annotation composer** — reactive annotation entry form with live thread updates
- **Widget feedback inbox** — live feed of all annotations on a specific widget for the widget owner
- **Cross-tenant widget embeds** — widget UIs embedded in third-party pages; RLS isolates each widget's data
---
## 3. Server-Side Components
React-like components that run entirely on the server. Rich interactive UI with a Haskell state machine — no JS state management needed.
### Structure
A component has:
- **State** — a Haskell data type
- **Actions** — a sum type
- **Render** — HSX function from state to HTML
- **`componentDidMount`** — hook for async data loading on connect
```haskell
-- Web/Component/AnnotationComposer.hs
data AnnotationComposer = AnnotationComposer
{ widgetId :: !(Id Widget)
, body :: !Text
, category :: !Text
, submitted :: !Bool
}
data AnnotationComposerController
= UpdateBodyAction { body :: !Text }
| UpdateCategoryAction { category :: !Text }
| SubmitAnnotationAction
deriving (Eq, Show, Data)
instance Component AnnotationComposer AnnotationComposerController where
initialState = AnnotationComposer
{ widgetId = error "set by mount"
, body = ""
, category = "friction"
, submitted = False
}
action state UpdateBodyAction { body } = pure state { body }
action state UpdateCategoryAction { category } = pure state { category }
action state SubmitAnnotationAction = do
createRecord (newRecord @Annotation
|> set #widgetId state.widgetId
|> set #body state.body
|> set #category state.category)
pure state { submitted = True, body = "" }
render state = [hsx|
<div class="annotation-composer">
<textarea
value={state.body}
onInput={callServerAction' UpdateBodyAction { body = inputValue }}
/>
<select onInput={callServerAction' UpdateCategoryAction { category = selectValue }}>
<option value="friction">Friction</option>
<option value="wish">Wish</option>
<option value="defect">Defect</option>
</select>
<button onclick={callServerAction SubmitAnnotationAction}>Submit</button>
</div>
|]
```
### Lifecycle
1. Client triggers action: `callServerAction('SubmitAnnotationAction')`
2. Server evaluates action handler → produces new state
3. Server re-renders → diffs HTML → sends granular DOM patch over WebSocket
4. `morphdom` applies patch (preserves third-party JS library DOM state)
### Registration
```haskell
-- Web/FrontController.hs
routeComponent @AnnotationComposer
```
```haskell
-- In a view:
{component @AnnotationComposer}
```
### Current Status
Described as "early development stage — expect bugs and API changes." Suitable for experimental use but not for core production paths yet.
### IHF Use Cases
- **Annotation entry UI** with live category validation and preview
- **Widget triage panel** with drag-to-sort and inline editing
- **Governance decision form** with multi-step workflow state
---
## 4. HTMX Integration
IHP pairs cleanly with htmx (both are hypermedia-first). Use `respondHtml` (partial renders without layout) in controller actions:
```haskell
action AppendAnnotationAction { widgetId } = do
let annotation = newRecord @Annotation
annotation
|> fill @'["body", "category"]
|> validateField #body nonEmpty
|> ifValid \case
Left _ -> respondHtml [hsx|<div class="error">Body required</div>|]
Right annotation -> do
annotation <- createRecord annotation
respondHtml (renderAnnotation annotation)
```
Reinitialise htmx after Turbolinks page transitions or AutoRefresh updates:
```javascript
document.addEventListener('turbolinks:load', () => htmx.process(document.body));
```
### IHF Use Cases
- **Governance ledger append** — POST an approval/rejection action → server appends to ledger and returns updated fragment
- **Annotation triage status update** — inline status toggle without page reload
- **Requirement candidate promotion** — single-button action that updates status and returns the updated card
---
## Decision Guide for IHF Phase 1
| Surface | Recommended Mechanism | Rationale |
|---------|----------------------|-----------|
| Hub dashboard (live signal counts, recent events) | AutoRefresh | Server-rendered, zero JS complexity |
| Widget detail page (event history) | AutoRefresh | Same — read-heavy, aggregate view |
| Annotation feed (live thread) | AutoRefresh or DataSync | AutoRefresh if all users see the same view; DataSync if per-user filtered |
| Annotation entry form | Server-Side Components (experimental) or HTMX | SSC for rich state machine; HTMX for simple append |
| Widget registration flow | Standard IHP forms | No realtime needed |
| Triage board (drag, inline edit) | Server-Side Components | Rich interaction state |