generated from coulomb/repo-seed
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:
272
docs/ihp-controllers-views-forms.md
Normal file
272
docs/ihp-controllers-views-forms.md
Normal 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`.
|
||||
224
docs/ihp-data-and-queries.md
Normal file
224
docs/ihp-data-and-queries.md
Normal 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
207
docs/ihp-ihf-mapping.md
Normal 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
243
docs/ihp-overview.md
Normal 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 → 500–800 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: 10–15 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
266
docs/ihp-realtime.md
Normal 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 |
|
||||
Reference in New Issue
Block a user