diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6ddc4f0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**inter-hub** is the reference implementation of the **Interaction Hub Framework (IHF)** — a governed, observable interaction substrate for hub-based AI-enabled software systems. It treats every UI element as a governed artifact, creating a full traceability chain from rendered widget → user interaction → structured feedback → requirement candidate → decision record → implementation change → observed outcome. + +**Current state:** Phase 0 (specification) is complete. Phase 1 (Minimal Interaction Core) is the active implementation target. No application code exists yet. + +For situational context, read `SCOPE.md`. For architecture depth, read `specs/InteractionHubFrameworkSpecification_v0.1.md`. + +## Stack + +- **IHP** (Integrated Haskell Platform) v1.5 — full-stack Haskell web framework, server-rendered + optional realtime +- **Haskell** (GHC 9.10) — strongly typed, functional +- **PostgreSQL** — canonical datastore, managed via Nix (no manual DB setup) +- **Nix / devenv** — reproducible environment +- **Tailwind CSS** — see `specs/TailwindForInteractionHubs_v0.2.md` for IHF-specific conventions + +## Development Setup + +Requires Determinate Nix + direnv: + +```bash +# One-time environment setup +curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh +nix profile install nixpkgs#ihp-new +nix profile add nixpkgs#direnv + +# Bootstrap IHP project (Phase 1, Task T01) +ihp-new inter-hub +cd inter-hub +devenv up +``` + +After `devenv up`: +- App server: `http://localhost:8000` +- IHP IDE + Schema Designer: `http://localhost:8001` + +## Key Commands + +```bash +devenv up # Start dev environment (app + postgres + file watchers) +migrate # Run pending migrations +test # Run tests (auto-creates temp Postgres DB) +make static/prod.js static/prod.css # Production asset bundle +deploy-to-nixos production # NixOS deploy +``` + +Schema editing: use the IHP IDE at `localhost:8001` or edit `Application/Schema.sql` directly. Code generation via `localhost:8001/Generators`. + +## Architecture + +### Core Domain Model (Phase 1) + +| Entity | Role | +|--------|------| +| `Hub` | Bounded domain of responsibility (Dev Hub, Ops Hub, etc.) | +| `Widget` | Smallest semantically governable interaction unit with stable ID | +| `WidgetVersion` | Version history of widget definitions | +| `InteractionEvent` | Recorded user/agent interaction (viewed, clicked, submitted, etc.) — **append-only** (enforced by PostgreSQL trigger) | +| `Annotation` | Structured comment attached to a widget with category | +| `ViewContext` | Logical location in the UI | +| `CapabilityReference` | Link to hub capability | + +### Traceability Chain + +``` +Widget → InteractionEvent / Annotation + → RequirementCandidate (Phase 2) + → DecisionRecord (Phase 3) + → ImplementationChange → DeploymentRecord → OutcomeSignal +``` + +### IHP Conventions + +- Controllers live in `Web/Controller/`, views in `Web/View/`, types in `Web/Types.hs` +- Schema changes go in `Application/Schema.sql`, then generate with IHP IDE +- Use `AutoRefresh` for operator dashboards (server push on DB change) — not DataSync or Server-Side Components in Phase 1 +- See `docs/ihp-ihf-mapping.md` for how IHP capabilities map to IHF requirements + +### Widget Envelope + +Every rendered widget wraps its HSX in a `widgetEnvelope` helper (Task T08) that injects the stable `widget-id` and `view-context` attributes, enabling client-side event capture without coupling to implementation. + +## UI Conventions + +All hub interfaces follow the Tailwind layer model in `specs/TailwindForInteractionHubs_v0.2.md`: + +``` +Semantic Role → Visual Primitive → Tailwind Token → Screen Composition +``` + +Key rules: +- Every interactive element belongs to a named semantic role (`action-primary`, `nav-item`, `data-cell`, etc.) +- Use spacing rhythm from the spec; do not invent ad-hoc spacing +- State cues (hover, active, disabled, error) follow the defined color roles + +## Required Environment Variables + +| Variable | Purpose | +|----------|---------| +| `IHP_SESSION_SECRET` | Session encryption key | +| `DATABASE_URL` | Postgres connection string | +| `IHP_BASEURL` | External URL (e.g., `https://example.com`) | + +## Active Workplan + +Phase 1 work is tracked in `workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md` (12 tasks, T01–T12). Use `/ralph-workplan workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md` to drive implementation loops. + +Phase 1 exit criteria: +- Widgets can be addressed and annotated reliably +- Interaction data is captured with actor attribution and view context +- Hub-level inspection of interaction signals is possible via a dashboard + +## Key Reference Docs + +| File | Purpose | +|------|---------| +| `SCOPE.md` | Situational guide — in/out of scope, terminology, entry points | +| `specs/InteractionHubFrameworkSpecification_v0.1.md` | Full IHF spec (8 phases, risks, design principles) | +| `specs/TailwindForInteractionHubs_v0.2.md` | Agent-optimized Tailwind coding guide | +| `docs/ihp-overview.md` | IHP v1.5 fundamentals and dev workflow | +| `docs/ihp-data-and-queries.md` | Schema design, auto-generated types, query builder, migrations | +| `docs/ihp-controllers-views-forms.md` | Controller patterns, HSX, forms, validation, auth | +| `docs/ihp-realtime.md` | AutoRefresh vs DataSync vs HTMX decision guide | +| `docs/ihp-ihf-mapping.md` | IHP capability → IHF requirement mapping with schema templates | + +## Related Repositories + +- `hub-core` — planned shared base package for domain/capability registration +- `the-custodian` — State Hub (decision records, workstreams) that IHF governance integrates with +- Downstream consumers: `dev-hub`, `ops-hub`, `fin-hub` diff --git a/SCOPE.md b/SCOPE.md new file mode 100644 index 0000000..7b608aa --- /dev/null +++ b/SCOPE.md @@ -0,0 +1,128 @@ +# SCOPE + +> This file helps you quickly understand what this repository is about, +> when it is relevant, and when it is not. +> It is intentionally lightweight and may be incomplete. + +--- + +## One-liner + +Specification and reference implementation of the Interaction Hub Framework (IHF) — a governed, observable interaction substrate for hub-based AI-enabled software systems. + +--- + +## Core Idea + +IHF treats every meaningful UI element as a **governed interaction artifact** rather than mere markup. It connects the full traceability chain from rendered widget → user interaction → structured feedback → requirement candidate → decision record → implementation change → observed outcome. This makes interaction observable, frustration actionable, and UI evolution evidence-based. + +--- + +## In Scope + +- Widget identity, lifecycle governance, and semantic addressability +- Interaction event capture and contextual enrichment +- Annotation and structured comment threads attached to widgets +- Requirements distillation from raw feedback clusters +- Governance ledger: decision records linked to requirements and implementations +- Outcome observation and pre/post change comparison +- AI-assisted distillation (bounded, attributable, reviewer-controlled) +- Cross-hub integration of interaction signals +- Platform APIs and conventions for framework-agnostic UI integration +- IHF specification documents and canonical artifact type definitions + +--- + +## Out of Scope + +- A complete universal frontend framework or design system +- Pixel-level visual design or CSS conventions +- Full product management methodology +- Replacement for DevOps observability tooling (ops-hub handles that) +- Unrestricted autonomous AI decision-making on requirements +- Mandatory single UI technology for all hub surfaces + +--- + +## Relevant When + +- Building or reviewing any hub dashboard or operator surface (dev-hub, ops-hub, fin-hub) +- Capturing and triaging user/operator feedback on existing hub UIs +- Defining governed widget identity for a new capability surface +- Implementing structured requirements distillation from interaction signals +- Designing cross-hub feedback routing or policy-linked decisions + +--- + +## Not Relevant When + +- Backend API development with no interaction surface +- Infrastructure provisioning or cluster operations (see railiance-*, ops-bridge) +- Data pipeline or SBOM work (see state-hub / dev-hub) +- One-off scripts or tooling with no end-user UI + +--- + +## Current State + +- Status: concept / early spec +- Implementation: Phase 0 complete (specification written); Phase 1 (Minimal Interaction Core) not yet started +- Stability: spec is draft — artifact model and vocabulary are stable enough for repo creation +- Usage: none yet; reference implementation target is IHP + PostgreSQL + +--- + +## How It Fits + +- Upstream dependencies: hub-core (for base models, domain/capability registration, MCP tools) — see CUST-WP-0025 +- Downstream consumers: dev-hub, ops-hub, fin-hub — any hub with an operator-facing surface +- Often used with: kaizen-agentic (agent-assist module), state-hub (decision records, requirement linkage) + +--- + +## Terminology + +- Preferred terms: Widget, Widget Envelope, Interaction Event, Annotation, Requirement Candidate, Decision Record, Outcome Signal +- Also known as: IHF, inter-hub +- Potentially confusing terms: "Hub" here = bounded domain of responsibility (Dev Hub, Ops Hub, etc.) — not the GitHub feature; "Widget" = governed semantic unit, not a visual component library widget + +--- + +## Related / Overlapping Repositories + +- `the-custodian` — provides state-hub (decision records, workstreams) that IHF governance ledger will integrate with +- `ops-bridge` — tunnel connectivity for remote hub surfaces +- `kaizen-agentic` — agent personas that map to IHF's Agent Integration Module (§9.8) +- `hub-core` — planned shared base package that IHF will depend on for domain/capability plumbing + +--- + +## Getting Oriented + +- Start with: `specs/InteractionHubFrameworkSpecification_v0.1.md` — the full IHF spec (19 sections) +- Key files / directories: `specs/` (specifications), root `SCOPE.md` (this file) +- Entry points: read IHF spec §6 (Key Concepts), §9 (Core Modules), §14 (Phased Implementation Plan) + +--- + +## Provided Capabilities + +```capability +type: framework +title: Governed interaction substrate +description: Provides widget registry, interaction event capture, annotation, and requirements distillation for hub-based AI systems. +keywords: [widget, interaction, feedback, annotation, requirements, governance, traceability] +``` + +```capability +type: specification +title: IHF specification and artifact model +description: Canonical artifact types and traceability chain from Widget → InteractionEvent → RequirementCandidate → DecisionRecord → OutcomeSignal. +keywords: [spec, artifact, traceability, widget, decision, outcome] +``` + +--- + +## Notes + +Phase 0 is complete (specification foundation). Implementation begins at Phase 1 (widget registry + interaction event capture). The spec is intentionally broader than the first implementation — IHP is the reference technology for Phase 1, but the framework is designed to survive UI technology changes (§12.7, §Phase 6). diff --git a/docs/ihp-controllers-views-forms.md b/docs/ihp-controllers-views-forms.md new file mode 100644 index 0000000..ef119b8 --- /dev/null +++ b/docs/ihp-controllers-views-forms.md @@ -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| +
+

{widget.name}

+
+ {forEach events renderEvent} +
+
+ |] +``` + +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) + {widget.name} + + -- Raw HTML (opt-out of escaping) +
{preEscapedToHtml trustedHtml}
+ + -- Conditionals + {if widget.status == "active" + then [hsx|Active|] + else [hsx|Inactive|]} + + -- Loops + + + -- Boolean attributes: True → present, False → omitted + + + -- Maybe attributes: Just → present, Nothing → omitted + Link + + -- Multiple root elements are OK (unlike JSX) +
Name
+
{widget.name}
+|] +``` + +**` + + + + +
{inner}
+ + +|] +``` + +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`. diff --git a/docs/ihp-data-and-queries.md b/docs/ihp-data-and-queries.md new file mode 100644 index 0000000..bac4984 --- /dev/null +++ b/docs/ihp-data-and-queries.md @@ -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/-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`). diff --git a/docs/ihp-ihf-mapping.md b/docs/ihp-ihf-mapping.md new file mode 100644 index 0000000..48c91e1 --- /dev/null +++ b/docs/ihp-ihf-mapping.md @@ -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 `
` 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. diff --git a/docs/ihp-overview.md b/docs/ihp-overview.md new file mode 100644 index 0000000..85a80e8 --- /dev/null +++ b/docs/ihp-overview.md @@ -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/.hs` | Action handlers, parameter binding, DB calls | +| View | `Web/View//.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/ # -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) diff --git a/docs/ihp-realtime.md b/docs/ihp-realtime.md new file mode 100644 index 0000000..5c3e611 --- /dev/null +++ b/docs/ihp-realtime.md @@ -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| + + {autoRefreshMeta} + + + + ... +|] +``` + +### 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| +
+