generated from coulomb/repo-seed
Compare commits
322 Commits
v0.2.0-alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf04534395 | |||
| eed4322055 | |||
| 74b2bf1ad1 | |||
| a280fb12cd | |||
| 752c87986c | |||
| 68c66b9cd9 | |||
| f8fde35e3e | |||
| 5e7b2cd11a | |||
| 210cd3fe61 | |||
| 3511a0c1f4 | |||
| b77e1bec14 | |||
| 24a8f34754 | |||
| 0833822e64 | |||
| 5101eb5c73 | |||
| 5c13de1b8f | |||
| 2e450e3a2d | |||
| 1ba64dd00f | |||
| e4e13ff1fd | |||
| 645590268e | |||
| e9a9eaa607 | |||
| 1a7e6afabf | |||
| d93185269b | |||
| c2009b300e | |||
| 333fbcc237 | |||
| fde5525170 | |||
| c685848af5 | |||
| 5663fab495 | |||
| 6f9e261eb1 | |||
| 5a686f4630 | |||
| 9020670bb3 | |||
| 5ac4c453b8 | |||
| a2d0dddddd | |||
| 84ee797e4f | |||
| 7cc3173f59 | |||
| fa96fb859a | |||
| 26708ba799 | |||
| a2c3a69b6e | |||
| 91037a4757 | |||
| ae9e4971d9 | |||
| a3d980c8c6 | |||
| 4381768045 | |||
| 45dbe81d57 | |||
| 5d5e810886 | |||
| 75ad691dd6 | |||
| e1c0f46a67 | |||
| 50735bb7cf | |||
| 4ebc04e1f4 | |||
| 0a4646bf44 | |||
| 301a7b96d0 | |||
| 790b5e5005 | |||
| 00df328214 | |||
| 61dfe126e8 | |||
| c74fb8fddd | |||
| f5eac4a4f2 | |||
| e8b0c7c554 | |||
| 08d662daca | |||
| 6078c48289 | |||
| 29f7895ce8 | |||
| 69b10469ab | |||
| bbb0be4f5f | |||
| 7194cfc880 | |||
| c28762ce92 | |||
| bee65a1bf6 | |||
| e7f84c5a2c | |||
| ca5ced7ac1 | |||
| 36c2b3874c | |||
| a8545c1fe6 | |||
| 515835ab05 | |||
| 9e579839af | |||
| 10c1317cf3 | |||
| 3e483e4785 | |||
| a8ecce80e9 | |||
| e0b0841f72 | |||
| 0dd8f5f9a9 | |||
| 0c9dd3dd65 | |||
| a60cc24914 | |||
| 8780f6ad86 | |||
| ea88176785 | |||
| 8aee7825c7 | |||
| 80512727cb | |||
| f39ec84b29 | |||
| b03781360b | |||
| f800d760c8 | |||
| a35009d509 | |||
| 2e6932f787 | |||
| b4415659d4 | |||
| 7866789303 | |||
| 568970a79f | |||
| 0d0c1564b4 | |||
| b2b070e7c2 | |||
| 591462105e | |||
| 3934481cfe | |||
| 89b9967d51 | |||
| e358382ec4 | |||
| ae33a711ed | |||
| 3f995e2e4b | |||
| 4c13dac5d0 | |||
| 4b94b6a8f9 | |||
| c1959b0b17 | |||
| 11ff61c1ba | |||
| accfec84ec | |||
| d6cf995f05 | |||
| 1ecf63e855 | |||
| 6e210561b2 | |||
| 15f6ef81c0 | |||
| 3534952f4a | |||
| 7d0c13319b | |||
| 5494e2b98b | |||
| 4a0d95ace9 | |||
| 968ba3d282 | |||
| e6705afa5b | |||
| d1cdf9a022 | |||
| f9c0bacc1c | |||
| eb996cb092 | |||
| ec8aa611b8 | |||
| a3d9a1effc | |||
| c078ec441b | |||
| 1050af9533 | |||
| 059637ca5b | |||
| e117f78ef3 | |||
| da556aa824 | |||
| 4c0d966d38 | |||
| 01902040da | |||
| f036df4f2c | |||
| 91204731ae | |||
| 3664af59f2 | |||
| 5fe1f7bfac | |||
| 5382a7672a | |||
| e19d7deef4 | |||
| 1bdbce96e6 | |||
| fba86d845f | |||
| 98a6d3bde4 | |||
| 881fef28cc | |||
| 9a7e6ad9f8 | |||
| ce18636038 | |||
| a9648f302f | |||
| 5378eb881e | |||
| 76347ae1b5 | |||
| 6526aa66c3 | |||
| a1dda36e50 | |||
| 2adade749e | |||
| 827c78287f | |||
| 563e19089a | |||
| f75d4196c3 | |||
| 5ba771c95c | |||
| 92a362d91a | |||
| e9f8e168ed | |||
| 007d1a2658 | |||
| e7a9a92b0f | |||
| b45a3020ea | |||
| 7386d3b357 | |||
| b6afbbfdf3 | |||
| adf3a3c5ab | |||
| 650abda494 | |||
| 00edb83101 | |||
| 7ba33f0ea4 | |||
| 1a9ac7f974 | |||
| d42352f46c | |||
| dfd8582095 | |||
| 5b8f0b9175 | |||
| e017169390 | |||
| 168a7ba763 | |||
| c3fd74fe03 | |||
| 922998ddd5 | |||
| 13b57c3482 | |||
| bc9cfabc6b | |||
| 81567d78d2 | |||
| a38a5d021b | |||
| 49b21e5467 | |||
| 13e626e5fe | |||
| 17688993c4 | |||
| f758fabb8a | |||
| 9a3232d743 | |||
| 21f591815d | |||
| 0ad18ed6a6 | |||
| fc76a5f58f | |||
| c19fa40ca6 | |||
| a12d5e0169 | |||
| b87bd2bca3 | |||
| d9f3b12616 | |||
| bb1dfd919e | |||
| e4250318e9 | |||
| 4e4b2ecc19 | |||
| ba66031938 | |||
| b2093274e1 | |||
| 0f3dba2c7b | |||
| caf93d8963 | |||
| c2e4bbd460 | |||
| 0646e157f3 | |||
| aec0491402 | |||
| 0dd36811f6 | |||
| 03499d16f1 | |||
| 46a8438a04 | |||
| bb9024f738 | |||
| 39ea81dbab | |||
| ff1da9d8d3 | |||
| b52e6a8536 | |||
| f1ed7f9fc2 | |||
| 831e859f92 | |||
| 746e18a4ad | |||
| 442d7034a3 | |||
| b9c3bb7f7b | |||
| d6de73ed61 | |||
| 5b144b6b96 | |||
| ad4e195a01 | |||
| 6d0350bd59 | |||
| c8c6c5c68b | |||
| 8ad2045dda | |||
| 1011557874 | |||
| c03badc9dd | |||
| 4b269ab653 | |||
| 607f93a634 | |||
| d104e02dbc | |||
| 662d8b156d | |||
| d825bac641 | |||
| 5be6302077 | |||
| 5033485b9c | |||
| 52ac9f64ea | |||
| 7cc722cbeb | |||
| 421408f369 | |||
| b38008ad94 | |||
| 30954a966d | |||
| c5179ce319 | |||
| a55c28bae4 | |||
| e572baf0f7 | |||
| 134c7ab6b7 | |||
| c0a00872d3 | |||
| 8de11861d0 | |||
| c8b85b0249 | |||
| d5847a48fd | |||
| e34c36c9ed | |||
| ff7c25dcff | |||
| 0f73061d41 | |||
| 6c8babf214 | |||
| df1d3fe118 | |||
| 9992623392 | |||
| 1f828316d2 | |||
| ea10ec41d1 | |||
| 269d5a5905 | |||
| 0999921491 | |||
| 2da69cebdf | |||
| 9cbfaef344 | |||
| cd1d06c6f8 | |||
| e1551d9bb7 | |||
| c5ae5530e2 | |||
| 6e1531be2c | |||
| 7432bd67d1 | |||
| 2c9af08319 | |||
| 223d3fe2b7 | |||
| 2d44c8471a | |||
| 3b5fbb3e22 | |||
| 2653a78f39 | |||
| c02bf68139 | |||
| a9e249a09d | |||
| e2e0e8ac3f | |||
| 5d70289128 | |||
| 2d5d507502 | |||
| a967b67f86 | |||
| f2dc802ee5 | |||
| 1c24c67eed | |||
| be47ff3dc8 | |||
| 2659ddfeed | |||
| 684bf01b8c | |||
| ca31dc051f | |||
| 03420ec622 | |||
| 77621a123b | |||
| 5ed75ac0bd | |||
| 616f521d13 | |||
| 619d2c7496 | |||
| 39e650d89a | |||
| 0bcb30246c | |||
| cd21723be0 | |||
| e0dcdbc263 | |||
| c62b127bdb | |||
| 8d5905ba4a | |||
| ed4c97dbc4 | |||
| 3283ad62ee | |||
| 4d788c2f8a | |||
| b018306f68 | |||
| f22bcc2993 | |||
| 51690c67d6 | |||
| 60aedb2e0a | |||
| c6362066ac | |||
| e52a2ba0e8 | |||
| 9cbf4caadf | |||
| 718fe3782b | |||
| e549501744 | |||
| 8534583573 | |||
| fdbfd8b1f4 | |||
| e57b7960d6 | |||
| 8165e90573 | |||
| 4e24c53188 | |||
| bef63aa14d | |||
| c78d1cbf3b | |||
| 31a6700078 | |||
| d0373c0e27 | |||
| 3fd6a33f45 | |||
| 3801b809ce | |||
| d830c386af | |||
| 0a72ee91ea | |||
| 72bc145abd | |||
| f802eb9198 | |||
| a25e1db5f8 | |||
| 3fc99d17ec | |||
| 452b1042a0 | |||
| ab5ecdcf9d | |||
| 0d68c667ed | |||
| eeafba8077 | |||
| 756f50ef46 | |||
| 3ec6f9e0b1 | |||
| df181d1dec | |||
| 35bd183a6d | |||
| 0edf05324e | |||
| 7c0ae6d17b | |||
| 12dfd5058e | |||
| 7598e26588 | |||
| 1e2d473257 | |||
| 750c9f25ff | |||
| 97294558e3 | |||
| abdfa8e034 | |||
| cc06bfdc90 | |||
| 26bc67ff79 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## Kaizen Agents
|
||||
|
||||
Specialized agent personas available on demand via the state-hub MCP.
|
||||
|
||||
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||
|
||||
Common agents:
|
||||
|
||||
| Agent | Category | When to use |
|
||||
|-------|----------|-------------|
|
||||
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||
| `project-management` | process | Track status, determine next steps |
|
||||
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||
|
||||
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Architecture
|
||||
|
||||
<!-- TODO: Describe the key design decisions and component structure.
|
||||
Key modules, data flows, external integrations, state machines, etc. -->
|
||||
|
||||
## Quick Reference
|
||||
|
||||
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## First Session Protocol
|
||||
|
||||
Triggered when `get_domain_summary("custodian")` shows **no workstreams**.
|
||||
The project is registered but work has not yet been structured.
|
||||
|
||||
**Step 1 — Read, don't write**
|
||||
- `~/the-custodian/canon/projects/custodian/project_charter_v0.1.md` — purpose, scope
|
||||
- `~/the-custodian/canon/projects/custodian/roadmap_v0.1.md` — planned phases
|
||||
- Scan repo root: README, directory structure, existing code or docs
|
||||
|
||||
**Step 2 — Survey in-progress work**
|
||||
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||
|
||||
**Step 3 — Propose workstreams to Bernd**
|
||||
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||
roadmap phase. **Wait for approval before creating.**
|
||||
|
||||
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||
```
|
||||
workplans/inter-hub-WP-NNNN-<slug>.md ← write this first
|
||||
```
|
||||
Then register in the hub:
|
||||
```
|
||||
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
|
||||
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||
```
|
||||
|
||||
**Step 5 — Record the setup**
|
||||
```
|
||||
add_progress_event(
|
||||
summary="First session: structured custodian into N workstreams, M tasks",
|
||||
event_type="milestone",
|
||||
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
||||
detail={"workstreams": [...], "tasks_created": M}
|
||||
)
|
||||
```
|
||||
|
||||
<!-- Delete or archive this file once past first session -->
|
||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Repo boundary
|
||||
|
||||
This repo owns **Interaction Hub Framework** only. It does not own:
|
||||
|
||||
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||
- SSH key management → railiance-infra/
|
||||
- State hub code → state-hub/
|
||||
-->
|
||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
||||
**Purpose:** Governed, observable interaction substrate for hub-based AI-enabled software systems (IHF specification and reference implementation).
|
||||
|
||||
**Domain:** custodian
|
||||
**Repo slug:** inter-hub
|
||||
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||
84
.claude/rules/session-protocol.md
Normal file
84
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,84 @@
|
||||
## Session Protocol
|
||||
|
||||
State Hub: http://127.0.0.1:8000
|
||||
|
||||
**Step 1 — Orient**
|
||||
|
||||
Read the offline-safe brief first — it works without a live hub connection:
|
||||
```bash
|
||||
cat .custodian-brief.md
|
||||
```
|
||||
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||
```
|
||||
get_domain_summary("custodian")
|
||||
```
|
||||
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||
```
|
||||
If the hub is offline: `cd ~/state-hub && make api`
|
||||
|
||||
**Step 2 — Check inbox**
|
||||
With MCP tools:
|
||||
```
|
||||
get_messages(to_agent="inter-hub", unread_only=True)
|
||||
```
|
||||
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||
requests before proceeding.
|
||||
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=inter-hub&unread_only=true" \
|
||||
| python3 -m json.tool
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
**Step 3 — Scan workplans**
|
||||
```bash
|
||||
ls workplans/
|
||||
```
|
||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`todo`/`in_progress` tasks.
|
||||
|
||||
**Step 4 — Present brief**
|
||||
|
||||
1. **Active workstreams** for `custodian` — title, task counts, blocking decisions
|
||||
2. **Pending tasks** from `workplans/` + any `[repo:inter-hub]` hub tasks
|
||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||
4. **Suggested next action** — highest-priority open item
|
||||
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||
|
||||
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||
|
||||
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||
|
||||
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||
|
||||
**Session close:**
|
||||
With MCP tools:
|
||||
```
|
||||
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
|
||||
```
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||
```
|
||||
If workplan files were modified, ensure the local copy is up to date first:
|
||||
```bash
|
||||
git -C <repo_path> pull --ff-only
|
||||
cd ~/state-hub && make fix-consistency REPO=inter-hub
|
||||
```
|
||||
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||
use the combined target which pulls before fixing:
|
||||
```bash
|
||||
cd ~/state-hub && make fix-consistency-remote REPO=inter-hub
|
||||
```
|
||||
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||
until you pull — intentional to prevent clobbering remote progress.
|
||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## Stack
|
||||
|
||||
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||
- **Language:**
|
||||
- **Key deps:**
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
# TODO: Fill in the standard commands for this repo
|
||||
|
||||
# Install dependencies
|
||||
|
||||
# Run tests
|
||||
|
||||
# Lint / type check
|
||||
|
||||
# Build / package (if applicable)
|
||||
```
|
||||
28
.claude/rules/workplan-convention.md
Normal file
28
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
File location: `workplans/inter-hub-WP-NNNN-<slug>.md`
|
||||
ID prefix: `INTER-WP`
|
||||
|
||||
Work items originate as files in this repo **before** being registered in the hub.
|
||||
|
||||
Canonical workplan/workstream frontmatter statuses are:
|
||||
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||
repo state, and `finished` when implementation is complete. `stalled` and
|
||||
`needs_review` are derived health labels, not stored statuses.
|
||||
|
||||
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||
prefix: `YYMMDD-inter-hub-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||
unchanged; the prefix is only for quick visual reference.
|
||||
|
||||
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||
multiple planned phases into a normal workplan.
|
||||
|
||||
Ecosystem todos from other agents arrive as `[repo:inter-hub]` hub tasks —
|
||||
visible at session start. Pick one up by creating the workplan file, then registering
|
||||
the workstream.
|
||||
|
||||
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"7a72372c-e488-456c-baba-1e60d38649cf","pid":9468,"procStart":"127351","acquiredAt":1777404376811}
|
||||
@@ -2,7 +2,7 @@
|
||||
# Custodian Brief — inter-hub
|
||||
|
||||
**Domain:** inter_hub
|
||||
**Last synced:** 2026-04-29 11:40 UTC
|
||||
**Last synced:** 2026-06-16 10:42 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Current Goal
|
||||
@@ -11,36 +11,29 @@ IHF Phase 1 complete — Phase 2 ready to start
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### Autonomous Error-Fix Loop: Reach Clean Build
|
||||
Progress: 0/5 done | workstream_id: `4636eb67-f7fb-409c-8d13-7fb461ef5db2`
|
||||
### Personal Dashboard Framework
|
||||
Progress: 0/4 done | workstream_id: `72fc022b-0196-492a-aaba-3475f8768f06`
|
||||
|
||||
**Open tasks:**
|
||||
- · E1 — Start compile-check and capture initial error set `0ddc0559`
|
||||
- · E2 — Fix Layer 2 errors (Application/Helper/*.hs) `2cd3dbb3`
|
||||
- · E3 — Fix Layer 3 errors (Web/Controller/*.hs and Web/View/**/*.hs) `99c4345c`
|
||||
- · E4 — Fix Layer 4 errors (Web/FrontController.hs, Web/Routes.hs) `c5dda487`
|
||||
- · E5 — Commit clean build and close WP-0014/A1 `e20d48ea`
|
||||
- · T01 — Research: Dashboard frameworks and patterns for inspiration `6074f195`
|
||||
- · T02 — Product Requirements Specification (PRS) `698304bc`
|
||||
- · T03 — Functional Design Document (FDD) `438e5771`
|
||||
- · T04 — Implementation workplan `970aa221`
|
||||
|
||||
### Local Deployment — Intro and Tutorial Web UI
|
||||
Progress: 0/7 done | workstream_id: `946d50b8-441c-4c0a-b1a0-2a4fb3340d16`
|
||||
### Ops Hub Evidence Intake for Activity Core
|
||||
Progress: 5/8 done | workstream_id: `bd086c41-287d-4a4e-8ac5-9ab270f14d72`
|
||||
|
||||
**Open tasks:**
|
||||
- · B1 — Create StaticPages controller `e08a4e99`
|
||||
- · B2 — Landing page view `2a2d4572`
|
||||
- · B3 — Capabilities page view `112311bd`
|
||||
- · B5 — Update root route to landing page `7dc59e27`
|
||||
- · B4 — Tutorial and extension guide views `818c86ed`
|
||||
- · B6 — Navigation integration `4155f30b`
|
||||
- · B7 — Final deployment run and verification `8e8568b3`
|
||||
- ! T03 - Prepare manifest vocabulary and seed widgets `94fc9806`
|
||||
- ! T04 - Provision the runtime API key outside Git `267db6a7`
|
||||
- ! T07 - Run end-to-end Inter-Hub submission smoke `23baee9b`
|
||||
|
||||
### Pre-flight: Close Deployment Gaps
|
||||
Progress: 2/6 done | workstream_id: `532761e7-7c97-42e6-a5ea-59a972a80230`
|
||||
### Ad hoc Inter-Hub production fixes
|
||||
Progress: 0/1 done | workstream_id: `9e7a50b4-da7f-4df9-9154-7b89a071f520`
|
||||
|
||||
**Open tasks:**
|
||||
- ► A2 — Fix compilation errors `40787dd7`
|
||||
- · A3 — Enable Tailwind CSS build pipeline `45389d55`
|
||||
- · A4 — Admin user seed migration `62a407f9`
|
||||
- · A5 — Smoke test `326397bc`
|
||||
- ! Fix COUNT decode failures in v2 bootstrap endpoints `cceee9f1`
|
||||
*(wait: Image publication/deploy requires authenticated Gitea Actions workflow dispatch or inspection of the self-hosted haskelseed runner path; tags f8fde35 and 68c66b9 still return manifest unknown.)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
89
.gitea/workflows/deploy.yaml
Normal file
89
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- ".custodian-brief.md"
|
||||
- ".sops.yaml"
|
||||
- "app.toml"
|
||||
- "deploy/railiance/**"
|
||||
- "docs/**"
|
||||
- "workplans/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-push-deploy:
|
||||
runs-on: [self-hosted, haskelseed]
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build OCI image
|
||||
shell: bash -l {0}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#docker \
|
||||
--accept-flake-config \
|
||||
--option lazy-trees false \
|
||||
--log-format bar-with-logs
|
||||
|
||||
- name: Push image to Gitea registry
|
||||
shell: bash -l {0}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
TOKEN=$(
|
||||
curl -fsS \
|
||||
"https://gitea.coulomb.social/v2/token?service=container_registry&scope=repository:coulomb/inter-hub:push,pull" \
|
||||
-u "tegwick:${REGISTRY_TOKEN}" \
|
||||
| awk -F'"' '/token/{print $4}'
|
||||
)
|
||||
if [ -z "${TOKEN}" ]; then
|
||||
echo "Failed to obtain Gitea registry token" >&2
|
||||
exit 1
|
||||
fi
|
||||
skopeo copy --insecure-policy \
|
||||
--dest-registry-token "${TOKEN}" \
|
||||
docker-archive:result \
|
||||
"docker://gitea.coulomb.social/coulomb/inter-hub:${SHA}"
|
||||
# Also tag as latest
|
||||
skopeo copy --insecure-policy \
|
||||
--dest-registry-token "${TOKEN}" \
|
||||
docker-archive:result \
|
||||
"docker://gitea.coulomb.social/coulomb/inter-hub:latest"
|
||||
echo "Pushed inter-hub:${SHA} and inter-hub:latest"
|
||||
|
||||
- name: Deploy to Railiance01
|
||||
shell: bash -l {0}
|
||||
env:
|
||||
KUBECONFIG: ${{ secrets.RAILIANCE01_KUBECONFIG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
helm upgrade --install inter-hub deploy/helm/inter-hub \
|
||||
--namespace inter-hub --create-namespace \
|
||||
--set image.tag="${SHA}" \
|
||||
--wait --timeout 5m
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Give the new pod time to start
|
||||
sleep 15
|
||||
curl -sf --retry 5 --retry-delay 5 https://hub.coulomb.social/ \
|
||||
| grep -q "inter-hub" && echo "Landing page OK"
|
||||
curl -s https://hub.coulomb.social/api/v2/widgets \
|
||||
-o /dev/null -w "%{http_code}" | grep -q "401" && echo "API auth gate OK"
|
||||
curl -fsS https://hub.coulomb.social/api/v2/hubs \
|
||||
| grep -q '"data"' && echo "Hub discovery OK"
|
||||
OPENAPI=$(curl -fsS https://hub.coulomb.social/api/v2/openapi.json)
|
||||
for path in /hubs /hub-capability-manifests /api-consumers /policy-scopes; do
|
||||
grep -q "\"${path}\"" <<< "${OPENAPI}" \
|
||||
&& echo "OpenAPI path present: ${path}" \
|
||||
|| { echo "OpenAPI path missing: ${path}" >&2; exit 1; }
|
||||
done
|
||||
8
.sops.yaml
Normal file
8
.sops.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
# SOPS encryption policy for inter-hub production handoff files.
|
||||
# Encrypt any file ending in .sops.yaml with the shared Railiance age recipient.
|
||||
|
||||
creation_rules:
|
||||
- path_regex: \.sops\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4
|
||||
162
AGENTS.md
Normal file
162
AGENTS.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Interaction Hub Framework — Agent Instructions
|
||||
|
||||
## Repo Identity
|
||||
|
||||
**Purpose:** Governed, observable interaction substrate for hub-based AI-enabled software systems (IHF specification and reference implementation).
|
||||
|
||||
**Domain:** custodian
|
||||
**Repo slug:** inter-hub
|
||||
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||
**Workplan prefix:** `INTER-WP-`
|
||||
|
||||
---
|
||||
|
||||
## State Hub Integration
|
||||
|
||||
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||
there is no MCP server for Codex agents.
|
||||
|
||||
| Context | URL |
|
||||
|---------|-----|
|
||||
| Local workstation | `http://127.0.0.1:8000` |
|
||||
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||
|
||||
### Orient at session start
|
||||
|
||||
```bash
|
||||
# Offline brief — works without hub connection
|
||||
cat .custodian-brief.md
|
||||
|
||||
# Active workstreams for this domain
|
||||
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||
| python3 -m json.tool
|
||||
|
||||
# Check inbox
|
||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=inter-hub&unread_only=true" \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
Mark a message read:
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
### Log progress (required at session close)
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"summary": "what was done",
|
||||
"event_type": "note",
|
||||
"author": "codex",
|
||||
"workstream_id": "<uuid>",
|
||||
"task_id": "<uuid>"
|
||||
}'
|
||||
```
|
||||
|
||||
Omit `workstream_id` / `task_id` when not applicable.
|
||||
|
||||
### Update task status
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "in_progress"}'
|
||||
# values: todo | in_progress | done | blocked
|
||||
```
|
||||
|
||||
### Flag a task for human review
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"needs_human": true, "intervention_note": "reason"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Protocol
|
||||
|
||||
**Start:**
|
||||
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||
2. Check inbox: `GET /messages/?to_agent=inter-hub&unread_only=true`; mark read
|
||||
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||
4. Check blocked tasks: `GET /tasks/?needs_human=true`
|
||||
|
||||
**During work:**
|
||||
- Update task statuses in workplan files as tasks progress
|
||||
- Record significant decisions via `POST /decisions/`
|
||||
|
||||
**Close:**
|
||||
1. Update workplan file task statuses to reflect progress
|
||||
2. Log: `POST /progress/` with a summary of what changed
|
||||
3. Note for the custodian operator: after workplan file changes, run from
|
||||
`~/state-hub`:
|
||||
```bash
|
||||
make fix-consistency REPO=inter-hub
|
||||
```
|
||||
This syncs task status from files into the hub DB.
|
||||
|
||||
---
|
||||
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
Work items originate as files in this repo — not in the hub. The hub is a
|
||||
read/cache/index layer that rebuilds from files.
|
||||
|
||||
**File location:** `workplans/INTER-WP-NNNN-<slug>.md`
|
||||
|
||||
**Archived location:** finished workplans may move to
|
||||
`workplans/archived/YYMMDD-INTER-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||
the completion/archive date; the frontmatter `id` does not change.
|
||||
|
||||
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||
this only for low-risk work completed directly; create a normal workplan for
|
||||
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||
|
||||
**Frontmatter:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: INTER-WP-NNNN
|
||||
type: workplan
|
||||
title: "..."
|
||||
domain: custodian
|
||||
repo: inter-hub
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
owner: codex
|
||||
topic_slug: ...
|
||||
created: "YYYY-MM-DD"
|
||||
updated: "YYYY-MM-DD"
|
||||
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
---
|
||||
```
|
||||
|
||||
Use `proposed` for a new draft, `ready` after review against current repo
|
||||
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||
derived health labels, not frontmatter statuses.
|
||||
|
||||
**Task block format** (one per `##` section):
|
||||
|
||||
```
|
||||
## Task Title
|
||||
|
||||
` ` `task
|
||||
id: INTER-WP-NNNN-T01
|
||||
status: todo | in_progress | done | blocked
|
||||
priority: high | medium | low
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
` ` `
|
||||
|
||||
Task description text.
|
||||
```
|
||||
|
||||
Status progression: `todo` → `in_progress` → `done` (or `blocked`)
|
||||
|
||||
To create a new workplan:
|
||||
1. Write the file following the format above
|
||||
2. Notify the custodian operator to run `make fix-consistency REPO=inter-hub`
|
||||
(or send a message to the hub agent via `POST /messages/`)
|
||||
@@ -27,7 +27,7 @@ checkRateLimitAndLog ::
|
||||
checkRateLimitAndLog consumer endpoint method = do
|
||||
-- Check rate limit: requests in last 60 seconds
|
||||
rows1 <- sqlQuery
|
||||
"SELECT COUNT(*) FROM api_request_log \
|
||||
"SELECT COUNT(*)::int FROM api_request_log \
|
||||
\WHERE api_consumer_id = ? AND requested_at >= NOW() - INTERVAL '60 seconds'"
|
||||
(Only consumer.id)
|
||||
let reqCount = case rows1 of
|
||||
@@ -43,7 +43,7 @@ checkRateLimitAndLog consumer endpoint method = do
|
||||
|
||||
-- Check daily quota
|
||||
rows2 <- sqlQuery
|
||||
"SELECT COUNT(*) FROM api_request_log \
|
||||
"SELECT COUNT(*)::int FROM api_request_log \
|
||||
\WHERE api_consumer_id = ? AND requested_at >= ? - INTERVAL '1 day'"
|
||||
(consumer.id, consumer.quotaResetsAt)
|
||||
let quotaUsed = case rows2 of
|
||||
|
||||
@@ -12,7 +12,7 @@ validateWidgetType ::
|
||||
Text -> IO (Either Text ())
|
||||
validateWidgetType name = do
|
||||
rows <- sqlQuery
|
||||
"SELECT COUNT(*) FROM widget_type_registry WHERE name = ? AND status = 'active'"
|
||||
"SELECT COUNT(*)::int FROM widget_type_registry WHERE name = ? AND status = 'active'"
|
||||
(Only name)
|
||||
case rows of
|
||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||
@@ -24,7 +24,7 @@ validateEventType ::
|
||||
Text -> IO (Either Text ())
|
||||
validateEventType name = do
|
||||
rows <- sqlQuery
|
||||
"SELECT COUNT(*) FROM event_type_registry WHERE name = ? AND status = 'active'"
|
||||
"SELECT COUNT(*)::int FROM event_type_registry WHERE name = ? AND status = 'active'"
|
||||
(Only name)
|
||||
case rows of
|
||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||
@@ -36,7 +36,7 @@ validateAnnotationCategory ::
|
||||
Text -> IO (Either Text ())
|
||||
validateAnnotationCategory name = do
|
||||
rows <- sqlQuery
|
||||
"SELECT COUNT(*) FROM annotation_category_registry WHERE name = ? AND status = 'active'"
|
||||
"SELECT COUNT(*)::int FROM annotation_category_registry WHERE name = ? AND status = 'active'"
|
||||
(Only name)
|
||||
case rows of
|
||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||
@@ -48,7 +48,7 @@ validatePolicyScope ::
|
||||
Text -> IO (Either Text ())
|
||||
validatePolicyScope name = do
|
||||
rows <- sqlQuery
|
||||
"SELECT COUNT(*) FROM policy_scope_registry WHERE name = ? AND status = 'active'"
|
||||
"SELECT COUNT(*)::int FROM policy_scope_registry WHERE name = ? AND status = 'active'"
|
||||
(Only name)
|
||||
case rows of
|
||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- Seed default admin user for initial local deployment.
|
||||
-- Password: admin1234!
|
||||
-- Hash generated with bcrypt cost 10 (compatible with IHP's authenticate @User).
|
||||
-- Hash generated with pwstore-fast (Crypto.PasswordStore.makePassword, strength 17)
|
||||
-- which is the format IHP's verifyPassword uses. NOT bcrypt.
|
||||
-- IMPORTANT: Change this password immediately after first login via the profile settings.
|
||||
-- Workplan: IHUB-WP-0014 (A4 — admin user seeding)
|
||||
|
||||
@@ -8,7 +9,7 @@ INSERT INTO users (id, email, password_hash, name, failed_login_attempts, create
|
||||
VALUES (
|
||||
uuid_generate_v4(),
|
||||
'admin@inter-hub.local',
|
||||
'$2b$10$c3imjL8nLkR1TSbBifvR3eFzlCUurGPXsN7K5trDjmZL6Af3zLqH.',
|
||||
'sha256|17|hyVUQpp0hhegCg2oM0lUHQ==|jSwCi+tJUlKCW6sT6nn23/r71fd0GSiVOo48JSrXyWc=',
|
||||
'Admin',
|
||||
0,
|
||||
now()
|
||||
|
||||
21
Application/Migration/1744588800-vsm-hub-metadata.sql
Normal file
21
Application/Migration/1744588800-vsm-hub-metadata.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- IHUB-WP-0019 T03 - first-class VSM hub metadata
|
||||
|
||||
ALTER TABLE hubs
|
||||
ADD COLUMN hub_family TEXT,
|
||||
ADD COLUMN vsm_function TEXT,
|
||||
ADD COLUMN vsm_system TEXT;
|
||||
|
||||
ALTER TABLE hubs
|
||||
ADD CONSTRAINT hubs_vsm_metadata_consistency CHECK (
|
||||
(hub_family IS NULL AND vsm_function IS NULL AND vsm_system IS NULL)
|
||||
OR (
|
||||
hub_family = 'vsm'
|
||||
AND vsm_function IS NOT NULL
|
||||
AND vsm_function <> ''
|
||||
AND vsm_system IN ('1', '2', '3', '3*', '4', '5', 'environment')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX hubs_hub_family_idx ON hubs (hub_family);
|
||||
CREATE INDEX hubs_vsm_system_idx ON hubs (vsm_system)
|
||||
WHERE vsm_system IS NOT NULL;
|
||||
@@ -25,7 +25,19 @@ CREATE TABLE hubs (
|
||||
domain TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||
api_key TEXT,
|
||||
hub_kind TEXT NOT NULL DEFAULT 'domain'
|
||||
hub_kind TEXT NOT NULL DEFAULT 'domain',
|
||||
hub_family TEXT,
|
||||
vsm_function TEXT,
|
||||
vsm_system TEXT,
|
||||
CONSTRAINT hubs_vsm_metadata_consistency CHECK (
|
||||
(hub_family IS NULL AND vsm_function IS NULL AND vsm_system IS NULL)
|
||||
OR (
|
||||
hub_family = 'vsm'
|
||||
AND vsm_function IS NOT NULL
|
||||
AND vsm_function <> ''
|
||||
AND vsm_system IN ('1', '2', '3', '3*', '4', '5', 'environment')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Widgets — smallest semantically governable interaction units
|
||||
@@ -557,6 +569,11 @@ CREATE INDEX hubs_hub_kind_idx ON hubs (hub_kind);
|
||||
CREATE UNIQUE INDEX hubs_one_framework_idx ON hubs (hub_kind)
|
||||
WHERE hub_kind = 'framework';
|
||||
|
||||
-- IHUB-WP-0019 T03 — first-class VSM hub metadata
|
||||
CREATE INDEX hubs_hub_family_idx ON hubs (hub_family);
|
||||
CREATE INDEX hubs_vsm_system_idx ON hubs (vsm_system)
|
||||
WHERE vsm_system IS NOT NULL;
|
||||
|
||||
-- T03 — Type registries
|
||||
|
||||
CREATE TABLE widget_type_registry (
|
||||
|
||||
189
CLAUDE.md
189
CLAUDE.md
@@ -1,180 +1,11 @@
|
||||
# CLAUDE.md
|
||||
# Interaction Hub Framework — Claude Code Instructions
|
||||
|
||||
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:** Phases 1–12 complete. IHF v0.2 specification fully implemented. GAAF scorecard at 3.68 (Strong). The full learning loop is closed: Widget → Annotation → RequirementCandidate → Requirement → DecisionRecord → DeploymentRecord → OutcomeSignal → OutcomeCorrelation / PatternPerformanceRecord / InstitutionalKnowledgeEntry → AdaptiveThresholdConfig → improved routing and triage.
|
||||
|
||||
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`.
|
||||
|
||||
## Compilation Layers
|
||||
|
||||
IHP compiles ~180 Haskell modules as one GHC target. Module changes are incremental — only changed modules and their dependents recompile. **Never modify Layer 1 during error-fix loops** — a change to `Web/Types.hs` or `Generated/Types.hs` invalidates all 59 controllers and 120 views simultaneously.
|
||||
|
||||
```
|
||||
Layer 1 — stable core (compile once):
|
||||
build/Generated/Types.hs IHP auto-generated from Schema.sql
|
||||
Web/Types.hs controller type definitions
|
||||
|
||||
Layer 2 — helpers (only touch if Layer 1 is clean):
|
||||
Application/Helper/*.hs 12 helper modules
|
||||
|
||||
Layer 3 — working surface (most errors live here):
|
||||
Web/Controller/*.hs 59 controllers
|
||||
Web/View/**/*.hs 120 views
|
||||
|
||||
Layer 4 — wiring (fix last):
|
||||
Web/FrontController.hs
|
||||
Web/Routes.hs
|
||||
```
|
||||
|
||||
**Compile tools** (run inside `devenv shell`):
|
||||
|
||||
```bash
|
||||
scripts/compile-check # full build via ghcid (all layers), log → /tmp/ihub-compile-errors.txt
|
||||
scripts/compile-check --bg # same, background-friendly (no colour/title)
|
||||
scripts/compile-check-core # Layer 1+2 only — verify clean base before touching Layer 3
|
||||
```
|
||||
|
||||
**GHC compilation mode**: IHP uses `-fbyte-code` — GHCi compiles modules to bytecode in memory, not to persistent `.o` files. The `-fwrite-interface` flag writes `.hi` type-info files alongside source modules; these survive session restarts and skip re-type-checking of unchanged modules. Each ghcid restart regenerates bytecode for the full module graph. Expect 20–60 min on first build; restarts are somewhat faster due to `.hi` caching but not dramatically so. `.hi` files are written next to their source files (e.g. `Web/Controller/Sessions.hi`).
|
||||
|
||||
**Error-fix discipline**: fix bottom-up (Layer 1 → 4). Fix one module at a time; wait for ghcid to reload before moving on. See `workplans/IHUB-WP-0016-build-infrastructure-and-error-loop.md` for the full SOP.
|
||||
|
||||
## 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`) |
|
||||
| `IHP_ANTHROPIC_API_KEY` | Anthropic API key for Phase 5 agent-assisted distillation |
|
||||
|
||||
## Active Workplan
|
||||
|
||||
IHF v0.2 is complete. All 12 phases and the GAAF Compliance Foundation are implemented. No active workplan.
|
||||
|
||||
Completed workplans: IHUB-WP-0001 (Phase 1), IHUB-WP-0002 (Phase 2), IHUB-WP-0003 (Phase 3), IHUB-WP-0004 (Phase 4), IHUB-WP-0005 (Phase 5), IHUB-WP-0006 (Phase 6), IHUB-WP-0007 (Phase 7), IHUB-WP-0008 (Phase 8), IHUB-WP-0009 (GAAF Compliance Foundation), IHUB-WP-0010 (Phase 9 — External API Surface and Consumer SDKs), IHUB-WP-0011 (Phase 10 — Hub Registry and Widget Marketplace), IHUB-WP-0012 (Phase 11 — Advanced AI Federation), IHUB-WP-0013 (Phase 12 — Platform Memory and Continuous Learning).
|
||||
|
||||
## GAAF Architecture Rules (enforced from IHUB-WP-0009)
|
||||
|
||||
These rules apply to all code written after Phase 8 completion:
|
||||
|
||||
1. **Type discriminator columns** (`widget_type`, `event_type`, `category`, `policy_scope`) must reference a registry table or carry a CHECK constraint. No bare TEXT for new type discriminators.
|
||||
2. **New hub-owned types** must be declared in the hub's `HubCapabilityManifest` before use. Register via the Extensions admin UI.
|
||||
3. **Core tables** (`widgets`, `interaction_events`, `annotations`, `hubs`, and their Phase 1–4 dependents) must not have new columns added without a corresponding update to `/contracts/core/`.
|
||||
4. **Append-only invariant** on `interaction_events` and `outcome_signals` is permanent. Never propose a migration that removes or bypasses those triggers.
|
||||
|
||||
## Key Reference Docs
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SCOPE.md` | Situational guide — in/out of scope, terminology, entry points |
|
||||
| `ARCHITECTURE-LAYERS.md` | GAAF-2026 layer map, scorecard, and compliance status |
|
||||
| `specs/InteractionHubFrameworkSpecification_v0.1.md` | Full IHF spec (Phases 0–8, risks, design principles) |
|
||||
| `specs/InteractionHubFrameworkSpecification_v0.2.md` | IHF extension spec (Phases 9–12) with GAAF foundation notes |
|
||||
| `specs/GoodSoftwareArchitectureFramework_2026.md` | GAAF-2026 standard — the architectural compliance framework |
|
||||
| `specs/TailwindForInteractionHubs_v0.2.md` | Agent-optimized Tailwind coding guide |
|
||||
| `contracts/README.md` | Contract catalog — all IHF contracts by layer |
|
||||
| `docs/domain-hub-extension-guide.md` | How to register a new domain hub (dev, ops, fin, sec) |
|
||||
| `docs/functional-modules.md` | Functional module maturity register |
|
||||
| `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 Haskell library for domain hub bootstrapping; `HubCapabilityManifest` in inter-hub provides the DB-side registration contract until hub-core is implemented
|
||||
- `the-custodian` — State Hub (decision records, workstreams) that IHF governance integrates with
|
||||
- Downstream consumers: `dev-hub`, `ops-hub`, `fin-hub`, `sec-hub` — must register via `HubCapabilityManifest` before creating hub-owned type discriminators
|
||||
@SCOPE.md
|
||||
@.claude/rules/repo-identity.md
|
||||
@.claude/rules/session-protocol.md
|
||||
@.claude/rules/first-session.md
|
||||
@.claude/rules/workplan-convention.md
|
||||
@.claude/rules/stack-and-commands.md
|
||||
@.claude/rules/architecture.md
|
||||
@.claude/rules/repo-boundary.md
|
||||
@.claude/rules/agents.md
|
||||
|
||||
426
HaskellVibePrimer.md
Normal file
426
HaskellVibePrimer.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# Haskell Vibe Primer
|
||||
## Hard-won lessons for coding agents working on inter-hub and IHP projects
|
||||
|
||||
---
|
||||
|
||||
## Quick orientation
|
||||
|
||||
Inter-hub is an IHP (Integrated Haskell Platform) v1.5 application on GHC 9.10.3, managed via Nix/devenv. It has two distinct compilation modes with fundamentally different behaviour:
|
||||
|
||||
| Mode | Command | Compiler | Output | Crashes? |
|
||||
|------|---------|----------|--------|----------|
|
||||
| Dev | `devenv up` → ghcid | GHCi byte-code | In-memory | No (byte-code avoids static linker) |
|
||||
| Production | `nix build .#docker` | Native (cabal) | OCI image | Yes, if archive bug present |
|
||||
|
||||
A build that works in `devenv up` does **not** guarantee `nix build .#docker` will succeed. The production build hits code paths that ghcid never reaches.
|
||||
|
||||
The build host for production is **haskelseed** (192.168.178.135, root/hcs26!x), not CoulombCore. Do not try to run `nix build` on CoulombCore.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Known GHC 9.10.3 bugs in this project
|
||||
|
||||
### Bug 1: `module M` re-export causes `.hi` overflow (FIXED in flake.nix)
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
Data.Binary.Get.runGet at position ~287000000: not enough bytes
|
||||
```
|
||||
Crash occurs while GHC reads a `.hi` (interface) file.
|
||||
|
||||
**Cause:** IHP generates a hub module `Generated/ActualTypes.hs` that originally used:
|
||||
```haskell
|
||||
module Generated.ActualTypes
|
||||
( module Generated.Foo
|
||||
, module Generated.Bar
|
||||
... -- 61 sub-modules
|
||||
) where
|
||||
import Generated.Foo
|
||||
import Generated.Bar
|
||||
```
|
||||
|
||||
The `module M` re-export syntax causes GHC to **embed the full exported interface of every sub-module verbatim** into `ActualTypes.hi`. With 61 sub-modules and thousands of typeclass instances, the resulting `.hi` reaches ~287 MB — exceeding GHC 9.10.3's `Data.Binary.Get` 274 MB deserialization limit.
|
||||
|
||||
**Fix (active in `flake.nix`):** The `inter-hub-models` postUnpack overlay rewrites the export list to explicit `T(..)` per-type exports:
|
||||
```haskell
|
||||
module Generated.ActualTypes
|
||||
( Foo(..)
|
||||
, FooId(..)
|
||||
, Bar(..)
|
||||
, ...
|
||||
) where
|
||||
```
|
||||
Explicit re-exports store only compact name-references in `.hi` (not embedded sub-interfaces). `ActualTypes.hi` drops from ~287 MB to ~51 KB.
|
||||
|
||||
**Rule: NEVER use `module M` re-export syntax for generated hub modules with many sub-modules.** Always use explicit `T(..)` exports.
|
||||
|
||||
```haskell
|
||||
-- BAD: embeds sub-interfaces into hub .hi
|
||||
module MyHub (module Sub1, module Sub2) where
|
||||
|
||||
-- GOOD: stores compact references
|
||||
module MyHub (Foo(..), Bar, baz) where
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bug 2: Truncated `libHSghc-9.10.3-5702.a` in Nix store (FIXED on haskelseed)
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
panic! (the 'impossible' happened)
|
||||
GHC version 9.10.3:
|
||||
Data.Binary.Get.runGet at position 287686318: not enough bytes
|
||||
```
|
||||
Crash occurs **after** `[477 of 477] Compiling Generated.WidgetVersionInclude` — all modules compiled, crash in post-compilation step.
|
||||
|
||||
**Cause:** The Nix-provisioned static archive for the GHC compiler-as-library is truncated:
|
||||
- Truncated: `ffg3yf2.../lib/.../ghc-9.10.3-5702/libHSghc-9.10.3-5702.a` — 287,768,576 bytes
|
||||
- Full archive: `ffg3yf2.../ghc-9.10.3-partial/lib/.../ghc-9.10.3-5702/libHSghc-9.10.3-5702.a` — 289,295,782 bytes
|
||||
|
||||
The last AR entry (`Expr.o`) claims 517,544 bytes but only 82,258 are present. GHC's `readAr` (via `Data.Binary.Get.runGet`) panics when it tries to read the full entry.
|
||||
|
||||
Why GHC reads this archive at all: The global `ghc-with-packages` package db is searched during the build (cabal configure does not pass `--package-db=clear`). That db registers the `ghc` compiler-as-library package with `library-dirs` pointing to the directory containing the truncated archive symlink. GHC loads this archive during internal post-compilation processing — even when the package has **no Template Haskell**.
|
||||
|
||||
**What does NOT fix it:**
|
||||
- `-fomit-interface-pragmas` — reduces `.hi` size, unrelated to archive loading
|
||||
- `--disable-shared` — affects output type, not input archive reading
|
||||
- `-fexternal-interpreter -pgmi ghc-iserv-dyn` — routes TH to external process, but inter-hub-models has **zero TH**; the archive read is from a different code path
|
||||
|
||||
**Fix applied on haskelseed (2026-05-02):**
|
||||
```bash
|
||||
TRUNCATED="/nix/store/ffg3yf2ypnbz3hc31y7nglrkihz0if01-ghc-9.10.3/lib/ghc-9.10.3/lib/x86_64-linux-ghc-9.10.3/ghc-9.10.3-5702/libHSghc-9.10.3-5702.a"
|
||||
FULL="/nix/store/ffg3yf2ypnbz3hc31y7nglrkihz0if01-ghc-9.10.3/ghc-9.10.3-partial/lib/ghc-9.10.3/lib/x86_64-linux-ghc-9.10.3/ghc-9.10.3-5702/libHSghc-9.10.3-5702.a"
|
||||
chmod u+w "$(dirname "$TRUNCATED")"
|
||||
cp "$FULL" "$TRUNCATED"
|
||||
chmod a-w "$TRUNCATED" "$(dirname "$TRUNCATED")"
|
||||
```
|
||||
|
||||
**If the fix is lost** (flake lock update changing GHC version):
|
||||
```bash
|
||||
# Check archive size before building — should be ~289 MB
|
||||
wc -c /nix/store/HASH-ghc-9.10.3/lib/ghc-9.10.3/lib/x86_64-linux-ghc-9.10.3/ghc-9.10.3-5702/libHSghc-9.10.3-5702.a
|
||||
# If ~287 MB, look for ghc-9.10.3-partial/ in same store path and apply patch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bug 3: `--disable-shared` causes missing `.dyn_hi` files (FIXED in flake.nix)
|
||||
|
||||
If `inter-hub-models` is built with `--disable-shared`, it produces only `.hi` files but no `.dyn_hi` (dynamic interface files). The dependent `inter-hub-lib` package (built with `--enable-shared` by default in NixPkgs) requires `.dyn_hi` from its dependencies and fails with:
|
||||
```
|
||||
Failed to load dynamic interface file for Generated.Foo:
|
||||
.../Generated/Foo.dyn_hi: does not exist
|
||||
```
|
||||
|
||||
Do not add `--disable-shared` to the inter-hub-models configureFlags unless you also suppress `--enable-shared` in inter-hub-lib (and its transitive dependents). The archive fix (Bug 2) is the correct solution; `--disable-shared` was a failed hypothesis.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — IHP compilation architecture
|
||||
|
||||
### Compilation layers (from CLAUDE.md)
|
||||
|
||||
```
|
||||
Layer 1 — stable core (compile once, expensive to change):
|
||||
build/Generated/Types.hs IHP auto-generated from Schema.sql
|
||||
Web/Types.hs controller/action type definitions
|
||||
|
||||
Layer 2 — helpers (only touch if Layer 1 is clean):
|
||||
Application/Helper/*.hs 12 helper modules
|
||||
|
||||
Layer 3 — working surface (most errors live here):
|
||||
Web/Controller/*.hs 59 controllers
|
||||
Web/View/**/*.hs 120 views
|
||||
|
||||
Layer 4 — wiring (fix last):
|
||||
Web/FrontController.hs
|
||||
Web/Routes.hs
|
||||
```
|
||||
|
||||
**Fix errors bottom-up, one module at a time.** Never touch Layer 1 during an error-fix loop. A single change to `Web/Types.hs` invalidates all 59 controllers and 120 views simultaneously — equivalent to a full rebuild.
|
||||
|
||||
### Package split
|
||||
|
||||
The project compiles as two separate Cabal packages:
|
||||
|
||||
| Package | Source | Contains |
|
||||
|---------|--------|----------|
|
||||
| `inter-hub-models` | `inter-hub-models-src/build/Generated/` | All IHP-generated types, ~477 modules |
|
||||
| `inter-hub-lib` | `inter-hub-lib-src/` | Application code: `Application/`, `Config/`, `Web/` |
|
||||
|
||||
Critical: **`inter-hub-lib-src` has no `build/` directory.** Generated modules come from the `inter-hub-models` package, not from the lib source tree. Any script that assumes lib-src contains `build/Generated/` will fail. To access generated file content from within inter-hub-lib's build scripts, find inter-hub-models-src dynamically:
|
||||
```bash
|
||||
_models_src=$(find /nix/store -maxdepth 1 -name "*-inter-hub-models-src" ! -name "*.drv" | head -1)
|
||||
```
|
||||
|
||||
### Generated code — what changes when schema changes
|
||||
|
||||
When `Application/Schema.sql` changes and you regenerate via the IHP IDE:
|
||||
- `build/Generated/Types.hs` — one-liner re-export hub (regenerated, do not edit)
|
||||
- `build/Generated/ActualTypes.hs` — type hub (regenerated, patched by postUnpack overlay)
|
||||
- `build/Generated/Foo.hs` — one per table, data type + instances
|
||||
- `build/Generated/FooInclude.hs` — `type instance Include` for eager loading
|
||||
- `build/Generated/ActualTypes/Foo.hs` — sub-module used by ActualTypes hub
|
||||
|
||||
After any schema change, check whether the postUnpack in `flake.nix` still correctly rewrites `Generated.ActualTypes` — run `nix build --keep-failed` and inspect the rewritten file.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Nix/devenv specifics
|
||||
|
||||
### How the Nix overlay works
|
||||
|
||||
The `flake.nix` has an `overlays = lib.mkAfter [...]` block in `devenv.shells.default` that overrides the Haskell package set. It intercepts derivations by `pname`:
|
||||
|
||||
```nix
|
||||
mkDerivation = args:
|
||||
let drv = hprev.mkDerivation args;
|
||||
in if (args.pname or "") == "inter-hub-models"
|
||||
then drv.overrideAttrs (old: { ... })
|
||||
else if (args.pname or "") == "inter-hub-lib"
|
||||
then drv.overrideAttrs (old: { ... })
|
||||
else drv;
|
||||
```
|
||||
|
||||
`postUnpack` runs after the source is unpacked but before configure. `postConfigure` runs after `setup configure`. Both run inside the Nix sandbox.
|
||||
|
||||
### Environment variables in Nix builds
|
||||
|
||||
- `$sourceRoot` — relative path to unpacked source (e.g., `inter-hub-models-src`)
|
||||
- `$TMPDIR` — build-specific temp directory (e.g., `/nix/var/nix/builds/nix-PID-RND/`)
|
||||
- `$NIX_BUILD_CORES` — parallelism (8 on haskelseed); NixPkgs Haskell builder passes `--ghc-option=-j$NIX_BUILD_CORES` by default
|
||||
- `$NIX_BUILD_TOP` — may not be set; prefer `$TMPDIR`
|
||||
|
||||
### Configure flags ordering matters
|
||||
|
||||
NixPkgs Haskell builder prepends its own configureFlags before yours. Your overlay's flags are **appended** via `(old.configureFlags or []) ++ [ your-flags ]`. For boolean flags (`--enable-X` / `--disable-X`), the last one wins. For `--ghc-option=-jN`, all are passed and GHC uses the last one.
|
||||
|
||||
Example of NixPkgs flags you must know about:
|
||||
```
|
||||
--enable-shared
|
||||
--enable-split-sections
|
||||
--ghc-option=-j8 (from NIX_BUILD_CORES)
|
||||
```
|
||||
|
||||
To override: append `--disable-shared`, `--disable-split-sections`, `--ghc-option=-j1` in your configureFlags. But beware: disabling shared breaks `.dyn_hi` for downstream packages.
|
||||
|
||||
### The `ghc-with-packages` symlink chain
|
||||
|
||||
The GHC wrapper used in builds (`ghc-with-packages`) exposes packages from both the wrapped GHC derivation and registered Haskell packages. Its `lib/` directory contains symlinks back into the unwrapped GHC derivation. When patching Nix store archives, verify the symlink chain resolves correctly:
|
||||
```bash
|
||||
readlink -f /nix/store/WRAP-ghc-9.10.3-with-packages/lib/.../libHSghc.a
|
||||
wc -c /nix/store/WRAP-ghc-9.10.3-with-packages/lib/.../libHSghc.a
|
||||
```
|
||||
|
||||
### Diagnosing `nix build` failures efficiently
|
||||
|
||||
```bash
|
||||
# Get full build log for a specific derivation
|
||||
nix log /nix/store/HASH-inter-hub-models-0.1.0.drv
|
||||
|
||||
# What derivations would be built (dry run)
|
||||
nix build .#docker --dry-run
|
||||
|
||||
# Keep failed build artifacts for inspection
|
||||
nix build .#docker --keep-failed
|
||||
|
||||
# Check if a store path exists (was the drv already built?)
|
||||
ls /nix/store/EXPECTED-OUTPUT-HASH 2>/dev/null && echo "cached" || echo "not built"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Haskell type system patterns
|
||||
|
||||
### Interface files (`.hi` and `.dyn_hi`)
|
||||
|
||||
GHC produces two kinds of interface files:
|
||||
- `.hi` — static interface, for non-shared compilations
|
||||
- `.dyn_hi` — dynamic interface, required when compiling with `--enable-shared` / `-dynamic-too`
|
||||
|
||||
A package compiled with `--disable-shared` produces `.hi` only. Any package depending on it that was compiled with `--enable-shared` will fail to load the `.dyn_hi`. Always maintain consistent shared/static settings across a package dependency chain.
|
||||
|
||||
### Type families vs Template Haskell
|
||||
|
||||
`type instance` declarations (like `Include "widgetId" ...`) are **type-level computations processed entirely by GHC during type-checking**. They are NOT Template Haskell, do NOT require the TH runtime, and do NOT trigger archive loading via the internal linker.
|
||||
|
||||
Template Haskell requires the TH evaluator (internal or external interpreter) only for modules with:
|
||||
- `{-# LANGUAGE TemplateHaskell #-}` pragma
|
||||
- Splice expressions `$(...)` or quasi-quotes `[| ... |]`
|
||||
- `deriveGeneric`, `makeLenses`, etc. (lens/generics libraries)
|
||||
|
||||
If a module has none of these, `-fexternal-interpreter` has no meaningful effect on it.
|
||||
|
||||
### `module M` re-export vs explicit exports
|
||||
|
||||
```haskell
|
||||
-- EMBEDS sub-interface into .hi (dangerous for large hubs):
|
||||
module Hub (module Sub) where
|
||||
import Sub
|
||||
|
||||
-- Stores compact name-reference in .hi (correct for large hubs):
|
||||
module Hub (Foo(..), Bar, baz) where
|
||||
import Sub (Foo(..), Bar, baz)
|
||||
```
|
||||
|
||||
The `module M` form is fine for small modules (< 10 re-exported sub-modules with modest interfaces). It becomes a GHC 9.10.3 bomb for generated hubs with 50+ sub-modules full of typeclass instances.
|
||||
|
||||
### AR archive format and `readAr`
|
||||
|
||||
GHC's internal static linker uses `Data.Binary.Get.runGet` to read AR archives (`.a` files). Unlike `ar t` (which only reads headers), `readAr` reads **all entry content**. A truncated archive that passes `ar t` will still panic `readAr`. If you see:
|
||||
|
||||
```
|
||||
panic! (the 'impossible' happened)
|
||||
Data.Binary.Get.runGet at position N: not enough bytes
|
||||
```
|
||||
|
||||
Run `ar tv /path/to/suspect.a | tail -5` — if the last entry shows a size, check whether `wc -c /path/to/suspect.a` is substantially smaller than expected.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Build compile tools and discipline
|
||||
|
||||
### Compile check scripts
|
||||
|
||||
Inside `devenv shell`:
|
||||
```bash
|
||||
scripts/compile-check # full build via ghcid (all layers), errors → /tmp/ihub-compile-errors.txt
|
||||
scripts/compile-check --bg # background-friendly (no colour/title escape codes)
|
||||
scripts/compile-check-core # Layer 1+2 only — verify clean base
|
||||
```
|
||||
|
||||
### Error-fix discipline
|
||||
|
||||
1. Fix **bottom-up**: Layer 1 → 2 → 3 → 4
|
||||
2. Fix **one module at a time** — wait for ghcid to reload before touching the next module
|
||||
3. **Never change Layer 1 (Web/Types.hs, Generated/Types.hs) while debugging Layer 3 errors** — it restarts the entire recompile
|
||||
4. `Generated/Types.hs` is auto-generated from `Application/Schema.sql`. Edit the schema in IHP IDE, not the generated file
|
||||
|
||||
### Reading GHC error messages
|
||||
|
||||
GHC errors reference **source locations**, not compiled output. When you see:
|
||||
```
|
||||
Web/Controller/Foo.hs:42:5: error:
|
||||
• Couldn't match expected type 'UUID' with actual type 'Text'
|
||||
```
|
||||
|
||||
The fix is in `Web/Controller/Foo.hs:42`, not in any generated file.
|
||||
|
||||
When you see:
|
||||
```
|
||||
Failed to load interface for 'Generated.Foo'
|
||||
```
|
||||
Check that `Generated.Foo` is in the `inter-hub-models` package and that models compiled successfully first.
|
||||
|
||||
### Parallel compilation flag (`-j`)
|
||||
|
||||
GHC's `-j` flag controls **parallel module compilation** within a single package. On haskelseed (2 CPU, ~3.8 GiB RAM), NixPkgs sets `-j8` (from `NIX_BUILD_CORES=8`). The overlay overrides to `-j1` for the models package to prevent OOM on the constrained host. Do not increase this without verifying available memory:
|
||||
```bash
|
||||
free -m # on haskelseed
|
||||
```
|
||||
|
||||
The env var `GHCRTS = "-A32m -M2g"` caps the GHC heap at 2 GiB and sets minor GC allocation to 32 MB. These are set in `devenv.shells.default.env`.
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Guardrails: expensive mistakes to avoid
|
||||
|
||||
### NEVER do these
|
||||
|
||||
| Action | Why it's expensive |
|
||||
|--------|-------------------|
|
||||
| Edit `build/Generated/Types.hs` directly | Regenerated on next schema sync; change is lost. Also, editing it changes Layer 1 and invalidates all 477 modules |
|
||||
| Add `module M` re-export syntax to a new generated hub module | Embeds sub-interfaces → `.hi` overflow → GHC crash |
|
||||
| Change `Web/Types.hs` or `Web/Types.hs` during a Layer 3 error loop | Restarts compile of all 59 controllers + 120 views |
|
||||
| Add `--disable-shared` to models without removing it from lib | Missing `.dyn_hi` crash in every downstream package |
|
||||
| Hardcode Nix store hashes in postUnpack scripts | Hash changes with every schema change; use `find /nix/store` instead |
|
||||
| Run `nix build .#docker` on CoulombCore | Insufficient RAM (< 4 GiB), will OOM during GHC compilation |
|
||||
| Trust `ar t` to validate an archive | `ar t` reads only headers; GHC reads content. Use `wc -c` and compare to expected size |
|
||||
| Assume `devenv up` success = `nix build` success | Different compilation modes; static linker issues only surface in `nix build` |
|
||||
|
||||
### Before modifying `flake.nix` overlays
|
||||
|
||||
1. **Check what derivation hash the current flake produces**: `nix build .#docker --dry-run`
|
||||
2. **Understand layer dependencies**: changing inter-hub-models configureFlags invalidates models AND all downstream (lib, binaries, docker image)
|
||||
3. **Test postUnpack scripts locally first**: simulate with `bash -n yourscript.sh` and run `awk` commands against sample files
|
||||
4. **Verify `.dyn_hi` will be produced** if `--enable-shared` is set (which is the NixPkgs default)
|
||||
|
||||
### Diagnosing a new crash before trying fixes
|
||||
|
||||
1. Get the full log: `nix log /nix/store/HASH-inter-hub-models-0.1.0.drv`
|
||||
2. Find the crash position: `Data.Binary.Get.runGet at position N`
|
||||
3. Determine which file is being read at that position (check file sizes of `.hi`, `.a`, `.conf` etc.)
|
||||
4. Check `ar tv suspect.a | tail -5` to see if the last AR entry header is valid
|
||||
5. Only then propose a fix — flag-based fixes almost never work for archive truncation issues
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — IHP-specific Haskell patterns
|
||||
|
||||
### IHP record access
|
||||
|
||||
IHP generates `HasField` instances. Access fields with:
|
||||
```haskell
|
||||
record.fieldName -- accessor (uses OverloadedRecordDot or get)
|
||||
record |> set #fieldName value -- functional update
|
||||
```
|
||||
|
||||
### IHP type family: `Include`
|
||||
|
||||
```haskell
|
||||
-- Generated/WidgetVersionInclude.hs (typical):
|
||||
type instance Include "widgetId" (WidgetVersion' widgetId) =
|
||||
WidgetVersion' (GetModelById widgetId)
|
||||
```
|
||||
|
||||
This is pure type-level. `Include` is a closed type family that fills in the concrete type for an association when you do eager loading with `fetchRelated`. It is NOT Template Haskell.
|
||||
|
||||
### IHP controller pattern
|
||||
|
||||
```haskell
|
||||
-- Each action is a function returning an IO Response (via implicit context)
|
||||
action ShowWidgetAction { widgetId } = do
|
||||
widget <- fetch widgetId
|
||||
render ShowView { .. }
|
||||
```
|
||||
|
||||
Controller type errors almost always mean a mismatch in `Web/Types.hs` — the action type declared there must match the implementation.
|
||||
|
||||
### Schema → Generated code cycle
|
||||
|
||||
```
|
||||
Edit Application/Schema.sql
|
||||
→ IHP IDE at localhost:8001 generates build/Generated/
|
||||
→ Regenerated: Types.hs, ActualTypes.hs, Foo.hs, FooInclude.hs
|
||||
→ Also regenerates Web/Types.hs (action types) and Web/Routes.hs
|
||||
→ Run compile-check to verify
|
||||
```
|
||||
|
||||
After schema changes, always verify the postUnpack overlay in `flake.nix` still produces valid Haskell. The `_types` / `_exports` awk scripts in postUnpack depend on the generated file structure remaining stable.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference card
|
||||
|
||||
```
|
||||
# Build status checks (on haskelseed)
|
||||
nix build .#docker --dry-run # what would be built?
|
||||
nix log /nix/store/HASH-inter-hub-models-0.1.0.drv # full build log
|
||||
wc -c /nix/store/ffg3yf2.../libHSghc-9.10.3-5702.a # verify archive size (must be ~289 MB)
|
||||
|
||||
# If archive is truncated (287 MB), apply patch:
|
||||
# Full archive is at same derivation's ghc-9.10.3-partial/ subdirectory
|
||||
# See project_ghc_build_fix.md in Claude memory for exact commands
|
||||
|
||||
# Compilation layers (in devenv shell)
|
||||
scripts/compile-check-core # Layer 1+2 only
|
||||
scripts/compile-check # Full build, errors → /tmp/ihub-compile-errors.txt
|
||||
|
||||
# Schema regeneration
|
||||
# Use IHP IDE at localhost:8001, then run compile-check
|
||||
|
||||
# Production build → push → deploy
|
||||
nix build .#docker
|
||||
skopeo copy docker-archive:result docker://92.205.130.254:32166/coulomb/inter-hub:$(git rev-parse --short HEAD)
|
||||
```
|
||||
118
Makefile
118
Makefile
@@ -13,5 +13,121 @@ JS_FILES += ${IHP}/static/vendor/turbolinks.js
|
||||
JS_FILES += ${IHP}/static/vendor/turbolinksInstantClick.js
|
||||
JS_FILES += ${IHP}/static/vendor/turbolinksMorphdom.js
|
||||
|
||||
include ${IHP}/Makefile.dist
|
||||
.DEFAULT_GOAL := help
|
||||
BOOTSTRAP_GOALS := help install install-nix doctor ui recovery-drill
|
||||
|
||||
ifneq ($(strip $(IHP)),)
|
||||
include ${IHP}/Makefile.dist
|
||||
else ifeq ($(strip $(MAKECMDGOALS)),)
|
||||
else ifneq ($(filter-out $(BOOTSTRAP_GOALS),$(MAKECMDGOALS)),)
|
||||
$(error IHP is not set. Run `make` to list setup targets, `make install` to prepare local tooling, or enter `devenv shell` before using IHP make targets)
|
||||
endif
|
||||
|
||||
.PHONY: help install install-nix doctor ui recovery-drill
|
||||
help:
|
||||
@printf '%s\n' 'inter-hub targets:'
|
||||
@printf ' %-17s %s\n' 'make install' 'Prepare local tooling; installs devenv when Nix is available.'
|
||||
@printf ' %-17s %s\n' 'make install-nix' 'Show the Nix installer command required before make install.'
|
||||
@printf ' %-17s %s\n' 'make doctor' 'Check whether devenv, nix, and direnv are visible.'
|
||||
@printf ' %-17s %s\n' 'make ui' 'Start the local Inter-Hub UI at http://localhost:8000.'
|
||||
@printf ' %-17s %s\n' 'make recovery-drill' 'Verify custody-backed SOPS recovery for the runtime Secret.'
|
||||
@printf '%s\n' ''
|
||||
@printf '%s\n' 'IHP targets are also available after entering the dev environment with devenv shell.'
|
||||
|
||||
install:
|
||||
@set -eu; \
|
||||
nix_bin=""; \
|
||||
if command -v devenv >/dev/null 2>&1; then \
|
||||
echo "devenv is already installed: $$(command -v devenv)"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
if command -v nix >/dev/null 2>&1; then \
|
||||
nix_bin="$$(command -v nix)"; \
|
||||
elif [ -x "$$HOME/.nix-profile/bin/nix" ]; then \
|
||||
nix_bin="$$HOME/.nix-profile/bin/nix"; \
|
||||
elif [ -x /nix/var/nix/profiles/default/bin/nix ]; then \
|
||||
nix_bin="/nix/var/nix/profiles/default/bin/nix"; \
|
||||
fi; \
|
||||
if [ -n "$$nix_bin" ]; then \
|
||||
if [ -d /nix/var/nix/daemon-socket ] && [ ! -x /nix/var/nix/daemon-socket ]; then \
|
||||
echo "Nix is installed at $$nix_bin, but this user cannot access the Nix daemon socket." >&2; \
|
||||
echo 'Typical fix: sudo usermod -aG nix-users "$$USER"' >&2; \
|
||||
echo "Then restart WSL or open a new login shell before rerunning: make install" >&2; \
|
||||
exit 126; \
|
||||
fi; \
|
||||
echo "Installing devenv with $$nix_bin"; \
|
||||
"$$nix_bin" --extra-experimental-features 'nix-command flakes' profile install github:cachix/devenv; \
|
||||
echo "Done. If your shell still cannot find devenv, open a new shell or add the Nix profile bin directory to PATH."; \
|
||||
else \
|
||||
echo "Nix is not available, so this target cannot install devenv yet." >&2; \
|
||||
echo "Run: make install-nix" >&2; \
|
||||
echo "Then rerun: make install" >&2; \
|
||||
echo "After Nix is available, this target installs devenv for you." >&2; \
|
||||
exit 127; \
|
||||
fi
|
||||
|
||||
install-nix:
|
||||
@printf '%s\n' 'Nix is the machine-level prerequisite for this repo.'
|
||||
@printf '%s\n' ''
|
||||
@printf '%s\n' 'Recommended next step: review and run the Determinate Nix installer:'
|
||||
@printf '%s\n' ' curl -fsSL https://install.determinate.systems/nix | sh -s -- install'
|
||||
@printf '%s\n' ''
|
||||
@printf '%s\n' 'If Nix is already installed but the daemon socket is not accessible:'
|
||||
@printf '%s\n' ' sudo usermod -aG nix-users "$$USER"'
|
||||
@printf '%s\n' ' # then restart WSL or open a new login shell'
|
||||
@printf '%s\n' ''
|
||||
@printf '%s\n' 'After installation, open a new shell and run:'
|
||||
@printf '%s\n' ' make install'
|
||||
@printf '%s\n' ' make ui'
|
||||
|
||||
doctor:
|
||||
@devenv_path="$$(command -v devenv || true)"; printf '%-8s %s\n' 'devenv:' "$${devenv_path:-not found}"
|
||||
@nix_path="$$(command -v nix || true)"; printf '%-8s %s\n' 'nix:' "$${nix_path:-not found}"
|
||||
@direnv_path="$$(command -v direnv || true)"; printf '%-8s %s\n' 'direnv:' "$${direnv_path:-not found}"
|
||||
@if [ -d /nix/var/nix/daemon-socket ]; then \
|
||||
if [ -x /nix/var/nix/daemon-socket ]; then \
|
||||
echo "nix daemon: accessible"; \
|
||||
else \
|
||||
echo "nix daemon: not accessible; current user may need the nix-users group"; \
|
||||
fi; \
|
||||
fi
|
||||
@if [ -x "$$HOME/.nix-profile/bin/nix" ]; then echo "nix profile: $$HOME/.nix-profile/bin/nix"; fi
|
||||
@if [ -x "$$HOME/.nix-profile/bin/devenv" ]; then echo "devenv profile: $$HOME/.nix-profile/bin/devenv"; fi
|
||||
@if [ -x /nix/var/nix/profiles/default/bin/nix ]; then echo "nix default profile: /nix/var/nix/profiles/default/bin/nix"; fi
|
||||
@if [ -x /nix/var/nix/profiles/default/bin/devenv ]; then echo "devenv default profile: /nix/var/nix/profiles/default/bin/devenv"; fi
|
||||
|
||||
recovery-drill:
|
||||
@deploy/railiance/recovery-drill.sh
|
||||
|
||||
ui:
|
||||
@echo "Starting inter-hub UI at http://localhost:8000"
|
||||
@if [ -d /nix/var/nix/daemon-socket ] && [ ! -x /nix/var/nix/daemon-socket ]; then \
|
||||
echo "Nix is installed, but this user cannot access the Nix daemon socket." >&2; \
|
||||
echo 'Typical fix: sudo usermod -aG nix-users "$$USER"' >&2; \
|
||||
echo "Then restart WSL or open a new login shell before rerunning: make ui" >&2; \
|
||||
exit 126; \
|
||||
fi; \
|
||||
if command -v devenv >/dev/null 2>&1; then \
|
||||
exec devenv up; \
|
||||
elif [ -x "$$HOME/.nix-profile/bin/devenv" ]; then \
|
||||
exec "$$HOME/.nix-profile/bin/devenv" up; \
|
||||
elif [ -x /nix/var/nix/profiles/default/bin/devenv ]; then \
|
||||
exec /nix/var/nix/profiles/default/bin/devenv up; \
|
||||
elif command -v nix >/dev/null 2>&1; then \
|
||||
echo "devenv is not on PATH; using nix run github:cachix/devenv fallback"; \
|
||||
exec nix --extra-experimental-features 'nix-command flakes' run github:cachix/devenv -- up; \
|
||||
elif [ -x "$$HOME/.nix-profile/bin/nix" ]; then \
|
||||
echo "devenv is not on PATH; using $$HOME/.nix-profile/bin/nix run github:cachix/devenv fallback"; \
|
||||
exec "$$HOME/.nix-profile/bin/nix" --extra-experimental-features 'nix-command flakes' run github:cachix/devenv -- up; \
|
||||
elif [ -x /nix/var/nix/profiles/default/bin/nix ]; then \
|
||||
echo "devenv is not on PATH; using /nix/var/nix/profiles/default/bin/nix run github:cachix/devenv fallback"; \
|
||||
exec /nix/var/nix/profiles/default/bin/nix --extra-experimental-features 'nix-command flakes' run github:cachix/devenv -- up; \
|
||||
elif command -v direnv >/dev/null 2>&1; then \
|
||||
echo "devenv is not on PATH; trying direnv exec . devenv up"; \
|
||||
exec direnv exec . devenv up; \
|
||||
else \
|
||||
echo "Could not find devenv or nix." >&2; \
|
||||
echo "Run make doctor to inspect the local tool path." >&2; \
|
||||
echo "Run make install after Nix is available to install devenv." >&2; \
|
||||
exit 127; \
|
||||
fi
|
||||
|
||||
6
SCOPE.md
6
SCOPE.md
@@ -65,8 +65,8 @@ IHF treats every meaningful UI element as a **governed interaction artifact** ra
|
||||
|
||||
## Current State
|
||||
|
||||
- Status: Phase 8 complete + GAAF compliance foundation complete (IHUB-WP-0009) — type registries, extension manifests, architectural contracts, and CI fitness functions in place; ready for Phase 9 (API versioning)
|
||||
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard); Phase 7 complete (FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation, friction heatmap, bottleneck dashboard, hub health history, operational review board); Phase 8 complete (WidgetOwnership, HubRoutingRule, FederatedPolicyOverlay, StewardshipRole, ArchiveRecord, delegated ownership, inter-hub routing, federated governance dashboard, lineage inspector)
|
||||
- Status: Phase 9 complete (IHUB-WP-0010) — type registries, extension manifests, architectural contracts, CI fitness functions, and versioned external API surface are in place.
|
||||
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard); Phase 7 complete (FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation, friction heatmap, bottleneck dashboard, hub health history, operational review board); Phase 8 complete (WidgetOwnership, HubRoutingRule, FederatedPolicyOverlay, StewardshipRole, ArchiveRecord, delegated ownership, inter-hub routing, federated governance dashboard, lineage inspector); Phase 9 complete (versioned `/api/v2`, OpenAPI, API consumers and keys, OAuth client credentials, generated SDKs, webhooks, API usage dashboard, and rate limiting)
|
||||
- Stability: core artifact model and schema are stable; Phase 6 contracts and Phase 8 activated policy overlays are immutable once active; native IHP widgets unaffected; Phase 7 observability scores are recomputed (not append-only), health snapshots are append-only; Phase 8 ownership records are soft-audit (no delete), archival is soft-delete (is_archived flag)
|
||||
- Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start
|
||||
|
||||
@@ -88,7 +88,7 @@ IHF treats every meaningful UI element as a **governed interaction artifact** ra
|
||||
|
||||
---
|
||||
|
||||
## Related / Overlapping Repositories
|
||||
## Related / Overlapping
|
||||
|
||||
- `the-custodian` — provides state-hub (decision records, workstreams) that IHF governance ledger will integrate with
|
||||
- `ops-bridge` — tunnel connectivity for remote hub surfaces
|
||||
|
||||
121
Test/Main.hs
121
Test/Main.hs
@@ -3,6 +3,21 @@ module Main where
|
||||
import Test.Hspec
|
||||
import IHP.Prelude
|
||||
import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary
|
||||
import Data.Aeson (Value(..), object, toJSON, (.=))
|
||||
import qualified Data.Aeson.Key as K
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import Web.Controller.Api.V2.InteractionEvents
|
||||
( declaredEventTypeNames, manifestAllowsEvent, metadataFromJsonBody
|
||||
, metadataParamOrEmpty
|
||||
)
|
||||
import Web.Controller.Api.V2.Hubs
|
||||
( missingRequiredFields, validCreateHubKind, validVsmMetadata
|
||||
, validVsmSystem )
|
||||
import Web.Controller.Api.V2.HubCapabilityManifests
|
||||
( jsonArrayTexts, textArrayFieldFromJsonBody )
|
||||
import Web.Controller.Api.V2.ApiConsumers (positiveLimit)
|
||||
import Web.Controller.Api.V2.OpenApi (buildPaths)
|
||||
import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus)
|
||||
|
||||
main :: IO ()
|
||||
main = hspec do
|
||||
@@ -10,4 +25,110 @@ main = hspec do
|
||||
it "should pass" do
|
||||
1 + 1 `shouldBe` (2 :: Int)
|
||||
|
||||
describe "API v2 interaction-event manifest validation" do
|
||||
let opsEventTypes = toJSON
|
||||
( [ "ops-endpoint-verified"
|
||||
, "ops-workflow-started"
|
||||
] :: [Text]
|
||||
)
|
||||
|
||||
it "decodes manifest-declared event types from JSON arrays" do
|
||||
declaredEventTypeNames opsEventTypes
|
||||
`shouldBe` ["ops-endpoint-verified", "ops-workflow-started"]
|
||||
|
||||
it "allows manifest-declared ops-owned domain events" do
|
||||
manifestAllowsEvent "ops-endpoint-verified" opsEventTypes
|
||||
`shouldBe` True
|
||||
|
||||
it "rejects events absent from an active manifest declaration" do
|
||||
manifestAllowsEvent "clicked" opsEventTypes
|
||||
`shouldBe` False
|
||||
|
||||
it "keeps empty declarations unrestricted for legacy manifests" do
|
||||
manifestAllowsEvent "clicked" (toJSON ([] :: [Text]))
|
||||
`shouldBe` True
|
||||
|
||||
it "preserves submitted metadata values and defaults missing metadata" do
|
||||
let metadata = object ["source" .= ("ops-hub" :: Text)]
|
||||
metadataFromJsonBody (object ["metadata" .= metadata]) `shouldBe` Just metadata
|
||||
metadataParamOrEmpty (Just metadata) `shouldBe` metadata
|
||||
metadataParamOrEmpty Nothing `shouldBe` object []
|
||||
|
||||
describe "API v2 hub and widget create validation" do
|
||||
it "accepts scriptable domain/shared hub kinds only" do
|
||||
validCreateHubKind "domain" `shouldBe` True
|
||||
validCreateHubKind "shared" `shouldBe` True
|
||||
validCreateHubKind "framework" `shouldBe` False
|
||||
|
||||
it "reports missing hub create fields including empty strings" do
|
||||
missingRequiredFields
|
||||
[ ("slug", Just "")
|
||||
, ("name", Nothing)
|
||||
, ("domain", Just "operations")
|
||||
]
|
||||
`shouldBe` ["slug", "name"]
|
||||
|
||||
it "accepts complete VSM hub classification for ops-hub" do
|
||||
validVsmMetadata (Just "vsm") (Just "operations") (Just "1")
|
||||
`shouldBe` True
|
||||
validVsmSystem "1" `shouldBe` True
|
||||
validVsmSystem "6" `shouldBe` False
|
||||
|
||||
it "rejects partial VSM metadata" do
|
||||
validVsmMetadata (Just "vsm") (Just "operations") Nothing
|
||||
`shouldBe` False
|
||||
validVsmMetadata Nothing (Just "operations") (Just "1")
|
||||
`shouldBe` False
|
||||
|
||||
it "accepts widget statuses supported by the UI create flow" do
|
||||
validWidgetStatus "active" `shouldBe` True
|
||||
validWidgetStatus "deprecated" `shouldBe` True
|
||||
validWidgetStatus "draft" `shouldBe` True
|
||||
validWidgetStatus "archived" `shouldBe` False
|
||||
|
||||
it "reports missing widget create fields including empty strings" do
|
||||
missingWidgetCreateFields
|
||||
[ ("hubId", Just "")
|
||||
, ("name", Just "Ops endpoint card")
|
||||
, ("widgetType", Nothing)
|
||||
]
|
||||
`shouldBe` ["hubId", "widgetType"]
|
||||
|
||||
describe "API v2 manifest vocabulary parsing" do
|
||||
it "decodes declared vocabulary arrays from JSON request bodies" do
|
||||
textArrayFieldFromJsonBody
|
||||
"declaredPolicyScopes"
|
||||
(object ["declaredPolicyScopes" .= (["ops-internal", "ops-external"] :: [Text])])
|
||||
`shouldBe` Just ["ops-internal", "ops-external"]
|
||||
|
||||
it "extracts manifest-declared text arrays for activation" do
|
||||
jsonArrayTexts (toJSON (["ops-endpoint-card", "ops-alert-panel"] :: [Text]))
|
||||
`shouldBe` ["ops-endpoint-card", "ops-alert-panel"]
|
||||
|
||||
describe "API v2 API consumer bootstrap validation" do
|
||||
it "requires positive rate-limit and quota values" do
|
||||
positiveLimit 1 `shouldBe` True
|
||||
positiveLimit 0 `shouldBe` False
|
||||
positiveLimit (-1) `shouldBe` False
|
||||
|
||||
describe "API v2 OpenAPI auth contract" do
|
||||
it "documents unauthenticated hub discovery for bootstrap clients" do
|
||||
openApiOperationSecurity "/hubs" "get" buildPaths
|
||||
`shouldBe` Just (toJSON ([] :: [Value]))
|
||||
|
||||
it "keeps hub creation authenticated" do
|
||||
openApiOperationSecurity "/hubs" "post" buildPaths
|
||||
`shouldBe` Just (toJSON [object ["BearerAuth" .= ([] :: [Text])]])
|
||||
|
||||
it "marks public vocabulary registries as unauthenticated" do
|
||||
openApiOperationSecurity "/policy-scopes" "get" buildPaths
|
||||
`shouldBe` Just (toJSON ([] :: [Value]))
|
||||
|
||||
LayerBoundary.spec
|
||||
|
||||
openApiOperationSecurity :: Text -> Text -> Value -> Maybe Value
|
||||
openApiOperationSecurity path method (Object paths) = do
|
||||
Object pathSpec <- KM.lookup (K.fromText path) paths
|
||||
Object operation <- KM.lookup (K.fromText method) pathSpec
|
||||
KM.lookup (K.fromText "security") operation
|
||||
openApiOperationSecurity _ _ _ = Nothing
|
||||
|
||||
@@ -10,10 +10,26 @@ import Web.Controller.Api.V2.Auth
|
||||
, respondWithStatus )
|
||||
import Application.Helper.TypeRegistry (validateAnnotationCategory)
|
||||
import qualified Data.UUID as UUID
|
||||
import Network.Wai (requestMethod)
|
||||
|
||||
instance Controller ApiV2AnnotationsController where
|
||||
|
||||
action ApiV2IndexAnnotationsAction = do
|
||||
case requestMethod ?request of
|
||||
"GET" -> listAnnotations
|
||||
"POST" -> createApiAnnotation
|
||||
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
action ApiV2ShowAnnotationAction { annotationId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
ann <- fetch annotationId
|
||||
renderJson (annotationToJson ann)
|
||||
|
||||
-- POST /api/v2/annotations
|
||||
action ApiV2CreateAnnotationAction = createApiAnnotation
|
||||
|
||||
listAnnotations :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
listAnnotations = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
|
||||
@@ -30,13 +46,8 @@ instance Controller ApiV2AnnotationsController where
|
||||
anns <- q2 |> limit perPage |> offset off |> fetch
|
||||
renderJson $ paginatedResponse (map annotationToJson anns) page perPage total
|
||||
|
||||
action ApiV2ShowAnnotationAction { annotationId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
ann <- fetch annotationId
|
||||
renderJson (annotationToJson ann)
|
||||
|
||||
-- POST /api/v2/annotations
|
||||
action ApiV2CreateAnnotationAction = do
|
||||
createApiAnnotation :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
createApiAnnotation = do
|
||||
_consumer <- requireApiConsumer
|
||||
let widgetIdText = paramOrNothing @Text "widgetId"
|
||||
category = paramOrNothing @Text "category"
|
||||
@@ -83,7 +94,7 @@ instance Controller ApiV2AnnotationsController where
|
||||
|> set #body bodyTxt
|
||||
|> set #actorType "api"
|
||||
|> createRecord
|
||||
renderJson (annotationToJson ann)
|
||||
respondWithStatus 201 (annotationToJson ann)
|
||||
|
||||
annotationToJson :: Annotation -> Value
|
||||
annotationToJson a = object
|
||||
|
||||
175
Web/Controller/Api/V2/ApiConsumers.hs
Normal file
175
Web/Controller/Api/V2/ApiConsumers.hs
Normal file
@@ -0,0 +1,175 @@
|
||||
module Web.Controller.Api.V2.ApiConsumers where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (Value, object, (.=))
|
||||
import Network.Wai (requestMethod)
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus, hashApiKey )
|
||||
import qualified Data.ByteString.Base16 as Base16
|
||||
import qualified Data.ByteString.Random as Random
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.UUID as UUID
|
||||
|
||||
instance Controller ApiV2ApiConsumersController where
|
||||
|
||||
action ApiV2IndexApiConsumersAction = do
|
||||
case requestMethod ?request of
|
||||
"GET" -> listApiConsumers
|
||||
"POST" -> createApiConsumerRecord
|
||||
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
action ApiV2ShowApiConsumerAction { apiConsumerId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
apiConsumer <- fetch apiConsumerId
|
||||
renderJson (apiConsumerToJson apiConsumer)
|
||||
|
||||
action ApiV2CreateApiConsumerAction = createApiConsumerRecord
|
||||
|
||||
action ApiV2CreateApiConsumerKeyAction { apiConsumerId } = do
|
||||
when (requestMethod ?request /= "POST") do
|
||||
respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
createApiConsumerKey apiConsumerId
|
||||
|
||||
listApiConsumers :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
listApiConsumers = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let pageOffset = (page - 1) * perPage
|
||||
total <- query @ApiConsumer |> fetchCount
|
||||
consumers <- query @ApiConsumer
|
||||
|> orderByDesc #createdAt
|
||||
|> limit perPage
|
||||
|> offset pageOffset
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map apiConsumerToJson consumers) page perPage total
|
||||
|
||||
createApiConsumerRecord :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
createApiConsumerRecord = do
|
||||
_consumer <- requireApiConsumer
|
||||
let name = paramOrNothing @Text "name"
|
||||
description = paramOrNothing @Text "description"
|
||||
rateLimit = fromMaybe 60 (paramOrNothing @Int "rateLimitPerMinute")
|
||||
quota = fromMaybe 10000 (paramOrNothing @Int "quotaPerDay")
|
||||
|
||||
when (maybe True (== "") name) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Missing required fields" :: Text)
|
||||
, "missing" .= (["name"] :: [Text])
|
||||
]
|
||||
unless (positiveLimit rateLimit) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("rateLimitPerMinute must be positive" :: Text)
|
||||
, "code" .= ("invalid_rate_limit" :: Text)
|
||||
]
|
||||
unless (positiveLimit quota) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("quotaPerDay must be positive" :: Text)
|
||||
, "code" .= ("invalid_quota" :: Text)
|
||||
]
|
||||
|
||||
mManifestId <- parseOptionalActiveManifestId
|
||||
let Just nameText = name
|
||||
apiConsumer <- newRecord @ApiConsumer
|
||||
|> set #name nameText
|
||||
|> set #description description
|
||||
|> set #hubCapabilityManifestId mManifestId
|
||||
|> set #rateLimitPerMinute rateLimit
|
||||
|> set #quotaPerDay quota
|
||||
|> createRecord
|
||||
respondWithStatus 201 (apiConsumerToJson apiConsumer)
|
||||
|
||||
createApiConsumerKey :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id ApiConsumer -> IO ()
|
||||
createApiConsumerKey apiConsumerId = do
|
||||
_requestingConsumer <- requireApiConsumer
|
||||
apiConsumer <- fetch apiConsumerId
|
||||
unless apiConsumer.isActive do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("API consumer is inactive" :: Text)
|
||||
, "code" .= ("api_consumer_inactive" :: Text)
|
||||
]
|
||||
let scopes = fromMaybe "" (paramOrNothing @Text "scopes")
|
||||
|
||||
fullKey <- generateApiKeySecret
|
||||
let prefix = T.take 8 fullKey
|
||||
keyHash = hashApiKey fullKey
|
||||
apiKey <- newRecord @ApiKey
|
||||
|> set #apiConsumerId apiConsumer.id
|
||||
|> set #keyPrefix prefix
|
||||
|> set #keyHash keyHash
|
||||
|> set #scopes scopes
|
||||
|> set #tokenType "static"
|
||||
|> createRecord
|
||||
respondWithStatus 201 (apiKeyCreatedToJson apiKey fullKey)
|
||||
|
||||
parseOptionalActiveManifestId :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO (Maybe (Id HubCapabilityManifest))
|
||||
parseOptionalActiveManifestId =
|
||||
case nonEmptyText =<< paramOrNothing @Text "hubCapabilityManifestId" of
|
||||
Nothing -> pure Nothing
|
||||
Just manifestIdRaw ->
|
||||
case UUID.fromText manifestIdRaw of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("hubCapabilityManifestId must be a valid UUID" :: Text)]
|
||||
Just rawId -> do
|
||||
let manifestId = Id rawId :: Id HubCapabilityManifest
|
||||
mManifest <- fetchOneOrNothing manifestId
|
||||
case mManifest of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("Hub capability manifest not found" :: Text)]
|
||||
Just manifest -> do
|
||||
unless (manifest.status == "active") do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Hub capability manifest must be active" :: Text)
|
||||
, "code" .= ("manifest_not_active" :: Text)
|
||||
]
|
||||
pure (Just manifestId)
|
||||
|
||||
generateApiKeySecret :: IO Text
|
||||
generateApiKeySecret = do
|
||||
rawBytes <- Random.random 32
|
||||
pure $ TE.decodeUtf8 (Base16.encode rawBytes)
|
||||
|
||||
apiConsumerToJson :: ApiConsumer -> Value
|
||||
apiConsumerToJson apiConsumer = object
|
||||
[ "id" .= apiConsumer.id
|
||||
, "name" .= apiConsumer.name
|
||||
, "description" .= apiConsumer.description
|
||||
, "hubCapabilityManifestId" .= apiConsumer.hubCapabilityManifestId
|
||||
, "rateLimitPerMinute" .= apiConsumer.rateLimitPerMinute
|
||||
, "quotaPerDay" .= apiConsumer.quotaPerDay
|
||||
, "quotaResetsAt" .= apiConsumer.quotaResetsAt
|
||||
, "isActive" .= apiConsumer.isActive
|
||||
, "createdAt" .= apiConsumer.createdAt
|
||||
, "updatedAt" .= apiConsumer.updatedAt
|
||||
]
|
||||
|
||||
apiKeyToJson :: ApiKey -> Value
|
||||
apiKeyToJson apiKey = object
|
||||
[ "id" .= apiKey.id
|
||||
, "apiConsumerId" .= apiKey.apiConsumerId
|
||||
, "keyPrefix" .= apiKey.keyPrefix
|
||||
, "scopes" .= apiKey.scopes
|
||||
, "tokenType" .= apiKey.tokenType
|
||||
, "expiresAt" .= apiKey.expiresAt
|
||||
, "revokedAt" .= apiKey.revokedAt
|
||||
, "lastUsedAt" .= apiKey.lastUsedAt
|
||||
, "createdAt" .= apiKey.createdAt
|
||||
]
|
||||
|
||||
apiKeyCreatedToJson :: ApiKey -> Text -> Value
|
||||
apiKeyCreatedToJson apiKey fullKey = object
|
||||
[ "apiKey" .= apiKeyToJson apiKey
|
||||
, "fullKey" .= fullKey
|
||||
, "displayOnce" .= True
|
||||
]
|
||||
|
||||
positiveLimit :: Int -> Bool
|
||||
positiveLimit value = value > 0
|
||||
|
||||
nonEmptyText :: Text -> Maybe Text
|
||||
nonEmptyText "" = Nothing
|
||||
nonEmptyText value = Just value
|
||||
274
Web/Controller/Api/V2/HubCapabilityManifests.hs
Normal file
274
Web/Controller/Api/V2/HubCapabilityManifests.hs
Normal file
@@ -0,0 +1,274 @@
|
||||
module Web.Controller.Api.V2.HubCapabilityManifests where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (Value(..), object, toJSON, (.=))
|
||||
import IHP.ControllerSupport (getHeader, requestBodyJSON)
|
||||
import Network.Wai (requestMethod)
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus )
|
||||
import Control.Monad (void)
|
||||
import Data.Maybe (mapMaybe)
|
||||
import Data.String (fromString)
|
||||
import qualified Data.Aeson.Key as K
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.UUID as UUID
|
||||
import qualified Data.Vector as V
|
||||
import Database.PostgreSQL.Simple (Only(..))
|
||||
|
||||
instance Controller ApiV2HubCapabilityManifestsController where
|
||||
|
||||
action ApiV2IndexHubCapabilityManifestsAction = do
|
||||
case requestMethod ?request of
|
||||
"GET" -> listManifests
|
||||
"POST" -> createManifest
|
||||
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
action ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId } = do
|
||||
case requestMethod ?request of
|
||||
"GET" -> showManifest hubCapabilityManifestId
|
||||
"PATCH" -> updateManifest hubCapabilityManifestId
|
||||
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
action ApiV2CreateHubCapabilityManifestAction = createManifest
|
||||
|
||||
action ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } =
|
||||
updateManifest hubCapabilityManifestId
|
||||
|
||||
action ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId } = do
|
||||
when (requestMethod ?request /= "POST") do
|
||||
respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
activateManifest hubCapabilityManifestId
|
||||
|
||||
listManifests :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
listManifests = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let pageOffset = (page - 1) * perPage
|
||||
mHubId = paramOrNothing @(Id Hub) "hubId"
|
||||
mStatus = paramOrNothing @Text "status"
|
||||
baseQ = query @HubCapabilityManifest |> orderByDesc #createdAt
|
||||
q1 = case mHubId of
|
||||
Just hubId -> baseQ |> filterWhere (#hubId, hubId)
|
||||
Nothing -> baseQ
|
||||
q2 = case mStatus of
|
||||
Just status -> q1 |> filterWhere (#status, status)
|
||||
Nothing -> q1
|
||||
total <- q2 |> fetchCount
|
||||
manifests <- q2
|
||||
|> limit perPage
|
||||
|> offset pageOffset
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map manifestToJson manifests) page perPage total
|
||||
|
||||
showManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id HubCapabilityManifest -> IO ()
|
||||
showManifest manifestId = do
|
||||
_consumer <- requireApiConsumer
|
||||
manifest <- fetch manifestId
|
||||
renderJson (manifestToJson manifest)
|
||||
|
||||
createManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
createManifest = do
|
||||
_consumer <- requireApiConsumer
|
||||
let hubIdText = paramOrNothing @Text "hubId"
|
||||
manifestVersion = fromMaybe "1.0" (nonEmptyText =<< paramOrNothing @Text "manifestVersion")
|
||||
capabilityDescription = paramOrNothing @Text "capabilityDescription"
|
||||
contact = paramOrNothing @Text "contact"
|
||||
|
||||
when (maybe True (== "") hubIdText) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Missing required fields" :: Text)
|
||||
, "missing" .= (["hubId"] :: [Text])
|
||||
]
|
||||
|
||||
let Just rawHubId = hubIdText
|
||||
case UUID.fromText rawHubId of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("hubId must be a valid UUID" :: Text)]
|
||||
Just rawId -> do
|
||||
let hubId = Id rawId :: Id Hub
|
||||
mHub <- fetchOneOrNothing hubId
|
||||
case mHub of
|
||||
Nothing -> respondWithStatus 422 $ object ["error" .= ("Hub not found" :: Text)]
|
||||
Just _hub -> do
|
||||
existing <- query @HubCapabilityManifest
|
||||
|> filterWhere (#hubId, hubId)
|
||||
|> fetchOneOrNothing
|
||||
when (isJust existing) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Hub already has a capability manifest" :: Text)
|
||||
, "code" .= ("manifest_already_exists" :: Text)
|
||||
]
|
||||
declaredWidgetTypes <- textArrayFieldFromRequestOrEmpty "declaredWidgetTypes"
|
||||
declaredEventTypes <- textArrayFieldFromRequestOrEmpty "declaredEventTypes"
|
||||
declaredAnnotationCategories <- textArrayFieldFromRequestOrEmpty "declaredAnnotationCategories"
|
||||
declaredPolicyScopes <- textArrayFieldFromRequestOrEmpty "declaredPolicyScopes"
|
||||
manifest <- newRecord @HubCapabilityManifest
|
||||
|> set #hubId hubId
|
||||
|> set #manifestVersion manifestVersion
|
||||
|> set #declaredWidgetTypes (toJSON declaredWidgetTypes)
|
||||
|> set #declaredEventTypes (toJSON declaredEventTypes)
|
||||
|> set #declaredAnnotationCategories (toJSON declaredAnnotationCategories)
|
||||
|> set #declaredPolicyScopes (toJSON declaredPolicyScopes)
|
||||
|> set #capabilityDescription capabilityDescription
|
||||
|> set #contact contact
|
||||
|> set #status "draft"
|
||||
|> createRecord
|
||||
respondWithStatus 201 (manifestToJson manifest)
|
||||
|
||||
updateManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id HubCapabilityManifest -> IO ()
|
||||
updateManifest manifestId = do
|
||||
_consumer <- requireApiConsumer
|
||||
manifest <- fetch manifestId
|
||||
unless (manifest.status == "draft") do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Active manifests are read-only" :: Text)
|
||||
, "code" .= ("manifest_read_only" :: Text)
|
||||
]
|
||||
|
||||
maybeDeclaredWidgetTypes <- textArrayFieldFromRequest "declaredWidgetTypes"
|
||||
maybeDeclaredEventTypes <- textArrayFieldFromRequest "declaredEventTypes"
|
||||
maybeDeclaredAnnotationCategories <- textArrayFieldFromRequest "declaredAnnotationCategories"
|
||||
maybeDeclaredPolicyScopes <- textArrayFieldFromRequest "declaredPolicyScopes"
|
||||
let manifestVersion = fromMaybe manifest.manifestVersion (nonEmptyText =<< paramOrNothing @Text "manifestVersion")
|
||||
capabilityDescription = fromMaybe manifest.capabilityDescription (Just <$> paramOrNothing @Text "capabilityDescription")
|
||||
contact = fromMaybe manifest.contact (Just <$> paramOrNothing @Text "contact")
|
||||
declaredWidgetTypes = maybe manifest.declaredWidgetTypes toJSON maybeDeclaredWidgetTypes
|
||||
declaredEventTypes = maybe manifest.declaredEventTypes toJSON maybeDeclaredEventTypes
|
||||
declaredAnnotationCategories = maybe manifest.declaredAnnotationCategories toJSON maybeDeclaredAnnotationCategories
|
||||
declaredPolicyScopes = maybe manifest.declaredPolicyScopes toJSON maybeDeclaredPolicyScopes
|
||||
|
||||
manifest <- manifest
|
||||
|> set #manifestVersion manifestVersion
|
||||
|> set #declaredWidgetTypes declaredWidgetTypes
|
||||
|> set #declaredEventTypes declaredEventTypes
|
||||
|> set #declaredAnnotationCategories declaredAnnotationCategories
|
||||
|> set #declaredPolicyScopes declaredPolicyScopes
|
||||
|> set #capabilityDescription capabilityDescription
|
||||
|> set #contact contact
|
||||
|> updateRecord
|
||||
renderJson (manifestToJson manifest)
|
||||
|
||||
activateManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id HubCapabilityManifest -> IO ()
|
||||
activateManifest manifestId = do
|
||||
_consumer <- requireApiConsumer
|
||||
manifest <- fetch manifestId
|
||||
when (manifest.status == "active") do
|
||||
respondWithStatus 200 (manifestToJson manifest)
|
||||
when (manifest.status == "retired") do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Retired manifests cannot be activated" :: Text)
|
||||
, "code" .= ("manifest_retired" :: Text)
|
||||
]
|
||||
|
||||
hub <- fetch manifest.hubId
|
||||
let wTypes = jsonArrayTexts manifest.declaredWidgetTypes
|
||||
eTypes = jsonArrayTexts manifest.declaredEventTypes
|
||||
cats = jsonArrayTexts manifest.declaredAnnotationCategories
|
||||
scopes = jsonArrayTexts manifest.declaredPolicyScopes
|
||||
|
||||
conflicts <- fmap concat $ sequence
|
||||
[ concat <$> mapM (checkConflict "widget_type_registry" hub.id) wTypes
|
||||
, concat <$> mapM (checkConflict "event_type_registry" hub.id) eTypes
|
||||
, concat <$> mapM (checkConflict "annotation_category_registry" hub.id) cats
|
||||
, concat <$> mapM (checkConflict "policy_scope_registry" hub.id) scopes
|
||||
]
|
||||
unless (null conflicts) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Manifest activation blocked by type conflicts" :: Text)
|
||||
, "code" .= ("manifest_type_conflict" :: Text)
|
||||
, "conflicts" .= conflicts
|
||||
]
|
||||
|
||||
mapM_ (upsertType "widget_type_registry" hub.id) wTypes
|
||||
mapM_ (upsertType "event_type_registry" hub.id) eTypes
|
||||
mapM_ (upsertType "annotation_category_registry" hub.id) cats
|
||||
mapM_ (upsertType "policy_scope_registry" hub.id) scopes
|
||||
now <- getCurrentTime
|
||||
manifest <- manifest
|
||||
|> set #status "active"
|
||||
|> set #activatedAt (Just now)
|
||||
|> updateRecord
|
||||
renderJson (manifestToJson manifest)
|
||||
|
||||
manifestToJson :: HubCapabilityManifest -> Value
|
||||
manifestToJson manifest = object
|
||||
[ "id" .= manifest.id
|
||||
, "hubId" .= manifest.hubId
|
||||
, "manifestVersion" .= manifest.manifestVersion
|
||||
, "declaredWidgetTypes" .= manifest.declaredWidgetTypes
|
||||
, "declaredEventTypes" .= manifest.declaredEventTypes
|
||||
, "declaredAnnotationCategories" .= manifest.declaredAnnotationCategories
|
||||
, "declaredPolicyScopes" .= manifest.declaredPolicyScopes
|
||||
, "capabilityDescription" .= manifest.capabilityDescription
|
||||
, "contact" .= manifest.contact
|
||||
, "status" .= manifest.status
|
||||
, "activatedAt" .= manifest.activatedAt
|
||||
, "createdAt" .= manifest.createdAt
|
||||
, "updatedAt" .= manifest.updatedAt
|
||||
]
|
||||
|
||||
textArrayFieldFromRequestOrEmpty :: (?context :: ControllerContext, ?request :: Request) => Text -> IO [Text]
|
||||
textArrayFieldFromRequestOrEmpty fieldName =
|
||||
fromMaybe [] <$> textArrayFieldFromRequest fieldName
|
||||
|
||||
textArrayFieldFromRequest :: (?context :: ControllerContext, ?request :: Request) => Text -> IO (Maybe [Text])
|
||||
textArrayFieldFromRequest fieldName =
|
||||
case getHeader "Content-Type" of
|
||||
Just contentType | "application/json" `BS.isPrefixOf` contentType -> do
|
||||
body <- requestBodyJSON
|
||||
pure $ textArrayFieldFromJsonBody fieldName body
|
||||
_ ->
|
||||
let values = paramList @Text (TE.encodeUtf8 fieldName)
|
||||
in pure $ if null values then Nothing else Just values
|
||||
|
||||
textArrayFieldFromJsonBody :: Text -> Value -> Maybe [Text]
|
||||
textArrayFieldFromJsonBody fieldName (Object body) =
|
||||
case KM.lookup (K.fromText fieldName) body of
|
||||
Just (Array values) -> Just (mapMaybe extractText (V.toList values))
|
||||
_ -> Nothing
|
||||
where
|
||||
extractText (String value) = Just value
|
||||
extractText _ = Nothing
|
||||
textArrayFieldFromJsonBody _ _ = Nothing
|
||||
|
||||
jsonArrayTexts :: Value -> [Text]
|
||||
jsonArrayTexts (Array values) = mapMaybe extractText (V.toList values)
|
||||
where
|
||||
extractText (String value) = Just value
|
||||
extractText _ = Nothing
|
||||
jsonArrayTexts _ = []
|
||||
|
||||
checkConflict ::
|
||||
(?modelContext :: ModelContext) =>
|
||||
Text -> Id Hub -> Text -> IO [Text]
|
||||
checkConflict tableName hubId name = do
|
||||
rows <- sqlQuery
|
||||
(fromString $ cs ("SELECT owner_hub_id FROM " <> tableName <> " WHERE name = ?"))
|
||||
(Only name)
|
||||
case rows of
|
||||
[] -> pure []
|
||||
[Only Nothing] -> pure []
|
||||
[Only (Just ownerId)] ->
|
||||
if ownerId == hubId
|
||||
then pure []
|
||||
else pure ["Type '" <> name <> "' in " <> tableName <> " is already owned by another hub"]
|
||||
_ -> pure []
|
||||
|
||||
upsertType ::
|
||||
(?modelContext :: ModelContext) =>
|
||||
Text -> Id Hub -> Text -> IO ()
|
||||
upsertType tableName hubId name =
|
||||
void $ sqlExec
|
||||
(fromString $ cs ("INSERT INTO " <> tableName <> " (name, label, owner_hub_id, status) "
|
||||
<> "VALUES (?, ?, ?, 'active') ON CONFLICT (name) DO NOTHING"))
|
||||
(name, name, hubId)
|
||||
|
||||
nonEmptyText :: Text -> Maybe Text
|
||||
nonEmptyText "" = Nothing
|
||||
nonEmptyText value = Just value
|
||||
@@ -61,6 +61,9 @@ hubDetailJson hub mManifest mSnapshot =
|
||||
, "slug" .= hub.slug
|
||||
, "domain" .= hub.domain
|
||||
, "hubKind" .= hub.hubKind
|
||||
, "hubFamily" .= hub.hubFamily
|
||||
, "vsmFunction" .= hub.vsmFunction
|
||||
, "vsmSystem" .= hub.vsmSystem
|
||||
, "gaafStatus" .= gaafIndicator
|
||||
, "manifest" .= fmap manifestSummary mManifest
|
||||
, "healthScore" .= fmap (.healthScore) mSnapshot
|
||||
|
||||
140
Web/Controller/Api/V2/Hubs.hs
Normal file
140
Web/Controller/Api/V2/Hubs.hs
Normal file
@@ -0,0 +1,140 @@
|
||||
module Web.Controller.Api.V2.Hubs where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (Value, object, (.=))
|
||||
import Network.Wai (requestMethod)
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus )
|
||||
|
||||
instance Controller ApiV2HubsController where
|
||||
|
||||
action ApiV2IndexHubsAction = do
|
||||
case requestMethod ?request of
|
||||
"GET" -> listHubs
|
||||
"POST" -> createApiHub
|
||||
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
action ApiV2ShowHubAction { hubId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
hub <- fetch hubId
|
||||
renderJson (hubToJson hub)
|
||||
|
||||
action ApiV2CreateHubAction = createApiHub
|
||||
|
||||
listHubs :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
listHubs = do
|
||||
(page, perPage) <- getPageParams
|
||||
let pageOffset = (page - 1) * perPage
|
||||
total <- query @Hub |> fetchCount
|
||||
hubs <- query @Hub
|
||||
|> orderByAsc #name
|
||||
|> limit perPage
|
||||
|> offset pageOffset
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map hubToJson hubs) page perPage total
|
||||
|
||||
createApiHub :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
createApiHub = do
|
||||
_consumer <- requireApiConsumer
|
||||
let slug = paramOrNothing @Text "slug"
|
||||
name = paramOrNothing @Text "name"
|
||||
domain = paramOrNothing @Text "domain"
|
||||
kind = fromMaybe "domain" (nonEmptyText =<< paramOrNothing @Text "hubKind")
|
||||
hubFamily = nonEmptyText =<< paramOrNothing @Text "hubFamily"
|
||||
vsmFunction = nonEmptyText =<< paramOrNothing @Text "vsmFunction"
|
||||
vsmSystem = nonEmptyText =<< paramOrNothing @Text "vsmSystem"
|
||||
|
||||
let missing = missingRequiredFields
|
||||
[ ("slug", slug)
|
||||
, ("name", name)
|
||||
, ("domain", domain)
|
||||
]
|
||||
unless (null missing) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Missing required fields" :: Text)
|
||||
, "missing" .= missing
|
||||
]
|
||||
|
||||
unless (validCreateHubKind kind) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Unsupported hubKind" :: Text)
|
||||
, "code" .= ("unsupported_hub_kind" :: Text)
|
||||
, "value" .= kind
|
||||
, "valid" .= validCreateHubKinds
|
||||
]
|
||||
|
||||
unless (validVsmMetadata hubFamily vsmFunction vsmSystem) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Invalid VSM hub metadata" :: Text)
|
||||
, "code" .= ("invalid_vsm_metadata" :: Text)
|
||||
, "hint" .= ("Use no VSM fields, or set hubFamily=vsm with vsmFunction and vsmSystem." :: Text)
|
||||
, "validVsmSystems" .= validVsmSystems
|
||||
]
|
||||
|
||||
let Just slugText = slug
|
||||
Just nameText = name
|
||||
Just domainText = domain
|
||||
|
||||
existing <- query @Hub
|
||||
|> filterWhere (#slug, slugText)
|
||||
|> fetchOneOrNothing
|
||||
when (isJust existing) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Hub slug already exists" :: Text)
|
||||
, "code" .= ("duplicate_hub_slug" :: Text)
|
||||
, "value" .= slugText
|
||||
]
|
||||
|
||||
hub <- newRecord @Hub
|
||||
|> set #slug slugText
|
||||
|> set #name nameText
|
||||
|> set #domain domainText
|
||||
|> set #hubKind kind
|
||||
|> set #hubFamily hubFamily
|
||||
|> set #vsmFunction vsmFunction
|
||||
|> set #vsmSystem vsmSystem
|
||||
|> createRecord
|
||||
respondWithStatus 201 (hubToJson hub)
|
||||
|
||||
hubToJson :: Hub -> Value
|
||||
hubToJson hub = object
|
||||
[ "id" .= hub.id
|
||||
, "slug" .= hub.slug
|
||||
, "name" .= hub.name
|
||||
, "domain" .= hub.domain
|
||||
, "hubKind" .= hub.hubKind
|
||||
, "hubFamily" .= hub.hubFamily
|
||||
, "vsmFunction" .= hub.vsmFunction
|
||||
, "vsmSystem" .= hub.vsmSystem
|
||||
, "createdAt" .= hub.createdAt
|
||||
]
|
||||
|
||||
validCreateHubKinds :: [Text]
|
||||
validCreateHubKinds = ["domain", "shared"]
|
||||
|
||||
validCreateHubKind :: Text -> Bool
|
||||
validCreateHubKind kind = kind `elem` validCreateHubKinds
|
||||
|
||||
validVsmSystems :: [Text]
|
||||
validVsmSystems = ["1", "2", "3", "3*", "4", "5", "environment"]
|
||||
|
||||
validVsmSystem :: Text -> Bool
|
||||
validVsmSystem systemName = systemName `elem` validVsmSystems
|
||||
|
||||
validVsmMetadata :: Maybe Text -> Maybe Text -> Maybe Text -> Bool
|
||||
validVsmMetadata Nothing Nothing Nothing = True
|
||||
validVsmMetadata (Just "vsm") (Just functionName) (Just systemName) =
|
||||
functionName /= "" && validVsmSystem systemName
|
||||
validVsmMetadata _ _ _ = False
|
||||
|
||||
missingRequiredFields :: [(Text, Maybe Text)] -> [Text]
|
||||
missingRequiredFields fields =
|
||||
[ name | (name, value) <- fields, maybe True (== "") value ]
|
||||
|
||||
nonEmptyText :: Text -> Maybe Text
|
||||
nonEmptyText "" = Nothing
|
||||
nonEmptyText value = Just value
|
||||
@@ -4,8 +4,8 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Text as T
|
||||
import Data.Aeson (Value(..), object, (.=))
|
||||
import IHP.ControllerSupport (getHeader, requestBodyJSON)
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus )
|
||||
@@ -13,12 +13,33 @@ import Application.Helper.TypeRegistry (validateEventType)
|
||||
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
||||
import Control.Concurrent (forkIO)
|
||||
import Control.Monad (void)
|
||||
import Data.Maybe (fromMaybe, mapMaybe)
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy.Char8 as LBSC
|
||||
import qualified Data.UUID as UUID
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Vector as V
|
||||
import Network.Wai (requestMethod)
|
||||
|
||||
instance Controller ApiV2InteractionEventsController where
|
||||
|
||||
action ApiV2IndexInteractionEventsAction = do
|
||||
case requestMethod ?request of
|
||||
"GET" -> listInteractionEvents
|
||||
"POST" -> createApiInteractionEvent
|
||||
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
action ApiV2ShowInteractionEventAction { interactionEventId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
event <- fetch interactionEventId
|
||||
renderJson (eventToJson event)
|
||||
|
||||
-- POST /api/v2/interaction-events
|
||||
action ApiV2CreateInteractionEventAction = createApiInteractionEvent
|
||||
|
||||
listInteractionEvents :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
listInteractionEvents = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
|
||||
@@ -36,14 +57,10 @@ instance Controller ApiV2InteractionEventsController where
|
||||
events <- q2 |> limit perPage |> offset off |> fetch
|
||||
renderJson $ paginatedResponse (map eventToJson events) page perPage total
|
||||
|
||||
action ApiV2ShowInteractionEventAction { interactionEventId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
event <- fetch interactionEventId
|
||||
renderJson (eventToJson event)
|
||||
|
||||
-- POST /api/v2/interaction-events
|
||||
action ApiV2CreateInteractionEventAction = do
|
||||
createApiInteractionEvent :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
createApiInteractionEvent = do
|
||||
consumer <- requireApiConsumer
|
||||
metadata <- metadataFromRequest
|
||||
let widgetIdText = paramOrNothing @Text "widgetId"
|
||||
eventType = paramOrNothing @Text "eventType"
|
||||
viewContext = paramOrNothing @Text "viewContext"
|
||||
@@ -76,9 +93,7 @@ instance Controller ApiV2InteractionEventsController where
|
||||
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
|
||||
manifest <- fetch manifestId
|
||||
when (manifest.status == "active") do
|
||||
let declared = case manifest.declaredEventTypes of
|
||||
_ -> [] :: [Text] -- JSONB array decoded via aeson
|
||||
unless (null declared || evType `elem` declared) do
|
||||
unless (manifestAllowsEvent evType manifest.declaredEventTypes) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Event type not declared in hub manifest" :: Text)
|
||||
, "code" .= ("event_type_not_in_manifest" :: Text)
|
||||
@@ -100,6 +115,7 @@ instance Controller ApiV2InteractionEventsController where
|
||||
|> set #eventType evType
|
||||
|> set #actorType "api"
|
||||
|> set #viewContextRef viewContext
|
||||
|> set #metadata metadata
|
||||
|> createRecord
|
||||
-- Dispatch webhooks fire-and-forget
|
||||
let webhookPayload = object
|
||||
@@ -109,8 +125,8 @@ instance Controller ApiV2InteractionEventsController where
|
||||
, "eventType" .= event.eventType
|
||||
, "occurredAt" .= event.occurredAt
|
||||
]
|
||||
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload
|
||||
renderJson (eventToJson event)
|
||||
liftIO $ void $ forkIO $ dispatchWebhooks evType webhookPayload
|
||||
respondWithStatus 201 (eventToJson event)
|
||||
|
||||
eventToJson :: InteractionEvent -> Value
|
||||
eventToJson e = object
|
||||
@@ -123,3 +139,34 @@ eventToJson e = object
|
||||
, "metadata" .= e.metadata
|
||||
, "occurredAt" .= e.occurredAt
|
||||
]
|
||||
|
||||
declaredEventTypeNames :: A.Value -> [Text]
|
||||
declaredEventTypeNames (Array values) = mapMaybe extractText (V.toList values)
|
||||
where
|
||||
extractText (String value) = Just value
|
||||
extractText _ = Nothing
|
||||
declaredEventTypeNames _ = []
|
||||
|
||||
manifestAllowsEvent :: Text -> A.Value -> Bool
|
||||
manifestAllowsEvent eventType declaredEventTypes =
|
||||
let declared = declaredEventTypeNames declaredEventTypes
|
||||
in null declared || eventType `elem` declared
|
||||
|
||||
metadataParamOrEmpty :: Maybe A.Value -> A.Value
|
||||
metadataParamOrEmpty = fromMaybe (object [])
|
||||
|
||||
metadataFromRequest :: (?context :: ControllerContext, ?request :: Request) => IO A.Value
|
||||
metadataFromRequest =
|
||||
case getHeader "Content-Type" of
|
||||
Just contentType | "application/json" `BS.isPrefixOf` contentType -> do
|
||||
body <- requestBodyJSON
|
||||
pure $ metadataParamOrEmpty (metadataFromJsonBody body)
|
||||
_ ->
|
||||
pure $ metadataParamOrEmpty (metadataFromText =<< paramOrNothing @Text "metadata")
|
||||
|
||||
metadataFromJsonBody :: A.Value -> Maybe A.Value
|
||||
metadataFromJsonBody (Object body) = KM.lookup "metadata" body
|
||||
metadataFromJsonBody _ = Nothing
|
||||
|
||||
metadataFromText :: Text -> Maybe A.Value
|
||||
metadataFromText raw = A.decode (LBSC.pack (cs raw))
|
||||
|
||||
@@ -10,13 +10,15 @@ import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=), Array, toJSON)
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.Key as K
|
||||
import qualified Data.Vector as V
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.Yaml as Yaml -- yaml package
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
import Application.Helper.TypeRegistry
|
||||
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
|
||||
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories
|
||||
, activePolicyScopes )
|
||||
import Network.HTTP.Types (status200)
|
||||
import Network.Wai (responseLBS)
|
||||
|
||||
@@ -47,10 +49,12 @@ buildOpenApiSpec = do
|
||||
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
|
||||
eventTypes <- activeEventTypes
|
||||
annCats <- activeAnnotationCategories
|
||||
policyScopes <- activePolicyScopes
|
||||
|
||||
let wtEnum = toJSON $ map (.name) allWidgetTypes
|
||||
let etEnum = toJSON $ map (.name) eventTypes
|
||||
let acEnum = toJSON $ map (.name) annCats
|
||||
let psEnum = toJSON $ map (.name) policyScopes
|
||||
|
||||
pure $ object
|
||||
[ "openapi" .= ("3.1.0" :: Text)
|
||||
@@ -76,6 +80,10 @@ buildOpenApiSpec = do
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "enum" .= acEnum
|
||||
]
|
||||
, "PolicyScope" .= object
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "enum" .= psEnum
|
||||
]
|
||||
, "PaginationMeta" .= object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
@@ -84,9 +92,22 @@ buildOpenApiSpec = do
|
||||
, "total" .= object ["type" .= ("integer" :: Text)]
|
||||
]
|
||||
]
|
||||
, "Hub" .= hubSchema
|
||||
, "CreateHubRequest" .= createHubRequestSchema
|
||||
, "HubCapabilityManifest" .= manifestSchema
|
||||
, "CreateHubCapabilityManifestRequest" .= createManifestRequestSchema
|
||||
, "UpdateHubCapabilityManifestRequest" .= updateManifestRequestSchema
|
||||
, "ApiConsumer" .= apiConsumerSchema
|
||||
, "CreateApiConsumerRequest" .= createApiConsumerRequestSchema
|
||||
, "ApiKey" .= apiKeySchema
|
||||
, "CreateApiKeyRequest" .= createApiKeyRequestSchema
|
||||
, "ApiKeyCreatedResponse" .= apiKeyCreatedResponseSchema
|
||||
, "Widget" .= widgetSchema
|
||||
, "CreateWidgetRequest" .= createWidgetRequestSchema
|
||||
, "InteractionEvent" .= interactionEventSchema
|
||||
, "CreateInteractionEventRequest" .= createInteractionEventRequestSchema
|
||||
, "Annotation" .= annotationSchema
|
||||
, "CreateAnnotationRequest" .= createAnnotationRequestSchema
|
||||
, "RequirementCandidate" .= rcSchema
|
||||
, "DecisionRecord" .= drSchema
|
||||
, "DeploymentRecord" .= depSchema
|
||||
@@ -94,6 +115,12 @@ buildOpenApiSpec = do
|
||||
, "OutcomeCorrelation" .= outcomeCorrelationSchema
|
||||
, "PatternPerformanceRecord" .= patternPerformanceSchema
|
||||
, "InstitutionalKnowledgeEntry" .= institutionalKnowledgeSchema
|
||||
, "HubRegistryEntry" .= hubRegistryEntrySchema
|
||||
, "HubManifestSummary" .= hubManifestSummarySchema
|
||||
, "WidgetPattern" .= widgetPatternSchema
|
||||
, "WidgetPatternDetail" .= widgetPatternDetailSchema
|
||||
, "WidgetPatternVersion" .= widgetPatternVersionSchema
|
||||
, "PatternAdoptionResponse" .= patternAdoptionResponseSchema
|
||||
]
|
||||
, "securitySchemes" .= object
|
||||
[ "BearerAuth" .= object
|
||||
@@ -108,7 +135,53 @@ buildOpenApiSpec = do
|
||||
|
||||
buildPaths :: Value
|
||||
buildPaths = object
|
||||
[ "/widgets" .= getListPath "Widget"
|
||||
[ "/hubs" .= object
|
||||
[ "get" .= publicPaginatedListOp "Hub" []
|
||||
, "post" .= writeOp "Hub" "CreateHubRequest"
|
||||
]
|
||||
, "/hubs/{id}" .= getShowPath "Hub"
|
||||
, "/hub-capability-manifests" .= object
|
||||
[ "get" .= listOp "HubCapabilityManifest"
|
||||
[ ("hubId", "string", "uuid")
|
||||
, ("status", "string", "")
|
||||
]
|
||||
, "post" .= writeOp "HubCapabilityManifest" "CreateHubCapabilityManifestRequest"
|
||||
]
|
||||
, "/hub-capability-manifests/{id}" .= object
|
||||
[ "get" .= showOp "HubCapabilityManifest"
|
||||
, "patch" .= writeOpWithStatusAndParams
|
||||
"Update HubCapabilityManifest"
|
||||
"HubCapabilityManifest"
|
||||
"UpdateHubCapabilityManifestRequest"
|
||||
True
|
||||
"200"
|
||||
[pathParam "id"]
|
||||
]
|
||||
, "/hub-capability-manifests/{id}/activate" .= object
|
||||
[ "post" .= postNoBodyOpWithStatusAndParams
|
||||
"Activate HubCapabilityManifest"
|
||||
"HubCapabilityManifest"
|
||||
"200"
|
||||
[pathParam "id"]
|
||||
]
|
||||
, "/api-consumers" .= object
|
||||
[ "get" .= listOp "ApiConsumer" []
|
||||
, "post" .= writeOp "ApiConsumer" "CreateApiConsumerRequest"
|
||||
]
|
||||
, "/api-consumers/{id}" .= getShowPath "ApiConsumer"
|
||||
, "/api-consumers/{id}/api-keys" .= object
|
||||
[ "post" .= writeOpWithResponseStatusAndParams
|
||||
"Create ApiKey"
|
||||
"ApiKeyCreatedResponse"
|
||||
"CreateApiKeyRequest"
|
||||
False
|
||||
"201"
|
||||
[pathParam "id"]
|
||||
]
|
||||
, "/widgets" .= object
|
||||
[ "get" .= listOp "Widget" []
|
||||
, "post" .= writeOp "Widget" "CreateWidgetRequest"
|
||||
]
|
||||
, "/widgets/{id}" .= getShowPath "Widget"
|
||||
, "/interaction-events" .= object
|
||||
[ "get" .= listOp "InteractionEvent"
|
||||
@@ -135,14 +208,19 @@ buildPaths = object
|
||||
, "/widget-types" .= publicListPath "WidgetTypeRegistry"
|
||||
, "/event-types" .= publicListPath "EventTypeRegistry"
|
||||
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
|
||||
, "/policy-scopes" .= publicListPath "PolicyScopeRegistry"
|
||||
, "/token" .= tokenPath
|
||||
-- Phase 10 — Hub Registry and Widget Marketplace
|
||||
, "/hub-registry" .= getListPath "HubRegistryEntry"
|
||||
, "/hub-registry/{hubId}" .= getShowPath "HubRegistryEntry"
|
||||
, "/hub-registry/{hubId}" .= getShowPathWithParam "HubRegistryEntry" "hubId"
|
||||
, "/widget-patterns" .= getListPath "WidgetPattern"
|
||||
, "/widget-patterns/{id}" .= getShowPath "WidgetPattern"
|
||||
, "/widget-patterns/{id}" .= getShowPath "WidgetPatternDetail"
|
||||
, "/widget-patterns/{id}/adopt" .= object
|
||||
[ "post" .= writeOp "PatternAdoption" "AdoptPatternRequest"
|
||||
[ "post" .= postNoBodyOpWithStatusAndParams
|
||||
"Adopt WidgetPattern"
|
||||
"PatternAdoptionResponse"
|
||||
"200"
|
||||
[pathParam "id"]
|
||||
]
|
||||
]
|
||||
|
||||
@@ -154,6 +232,10 @@ getShowPath :: Text -> Value
|
||||
getShowPath schemaName = object
|
||||
[ "get" .= showOp schemaName ]
|
||||
|
||||
getShowPathWithParam :: Text -> Text -> Value
|
||||
getShowPathWithParam schemaName paramName = object
|
||||
[ "get" .= showOpWithParam schemaName paramName ]
|
||||
|
||||
listOp :: Text -> [(Text, Text, Text)] -> Value
|
||||
listOp schemaName extraParams = object
|
||||
[ "summary" .= ("List " <> schemaName)
|
||||
@@ -186,11 +268,45 @@ listOp schemaName extraParams = object
|
||||
, "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else [])
|
||||
]
|
||||
|
||||
publicPaginatedListOp :: Text -> [(Text, Text, Text)] -> Value
|
||||
publicPaginatedListOp schemaName extraParams = object
|
||||
[ "summary" .= ("List " <> schemaName)
|
||||
, "security" .= ([] :: [Value])
|
||||
, "parameters" .= (pageParams ++ map toParam extraParams)
|
||||
, "responses" .= object
|
||||
[ "200" .= object
|
||||
[ "description" .= ("OK" :: Text)
|
||||
, "content" .= object
|
||||
[ "application/json" .= object
|
||||
[ "schema" .= object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "data" .= object
|
||||
[ "type" .= ("array" :: Text)
|
||||
, "items" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]
|
||||
]
|
||||
, "meta" .= object ["$ref" .= ("#/components/schemas/PaginationMeta" :: Text)]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
where
|
||||
toParam (name, typ, fmt) = object $
|
||||
[ "name" .= name, "in" .= ("query" :: Text)
|
||||
, "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else [])
|
||||
]
|
||||
|
||||
showOp :: Text -> Value
|
||||
showOp schemaName = object
|
||||
showOp schemaName = showOpWithParam schemaName "id"
|
||||
|
||||
showOpWithParam :: Text -> Text -> Value
|
||||
showOpWithParam schemaName paramName = object
|
||||
[ "summary" .= ("Get " <> schemaName)
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
, "parameters" .= [object ["name" .= ("id" :: Text), "in" .= ("path" :: Text), "required" .= True, "schema" .= object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]]]
|
||||
, "parameters" .= [pathParam paramName]
|
||||
, "responses" .= object
|
||||
[ "200" .= object
|
||||
[ "description" .= ("OK" :: Text)
|
||||
@@ -205,27 +321,73 @@ showOp schemaName = object
|
||||
]
|
||||
|
||||
writeOp :: Text -> Text -> Value
|
||||
writeOp schemaName _reqSchema = object
|
||||
[ "summary" .= ("Create " <> schemaName)
|
||||
writeOp schemaName reqSchema = writeOpWithSummary ("Create " <> schemaName) schemaName reqSchema
|
||||
|
||||
writeOpWithSummary :: Text -> Text -> Text -> Value
|
||||
writeOpWithSummary summaryText schemaName reqSchema =
|
||||
writeOpWithStatusAndParams summaryText schemaName reqSchema True "201" []
|
||||
|
||||
writeOpWithStatusAndParams :: Text -> Text -> Text -> Bool -> Text -> [Value] -> Value
|
||||
writeOpWithStatusAndParams = writeOpWithResponseStatusAndParams
|
||||
|
||||
writeOpWithResponseStatusAndParams :: Text -> Text -> Text -> Bool -> Text -> [Value] -> Value
|
||||
writeOpWithResponseStatusAndParams summaryText responseSchema reqSchema bodyRequired successStatus params = object
|
||||
[ "summary" .= summaryText
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
, "parameters" .= params
|
||||
, "requestBody" .= object
|
||||
[ "required" .= True
|
||||
[ "required" .= bodyRequired
|
||||
, "content" .= object
|
||||
[ "application/json" .= object
|
||||
["schema" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]]
|
||||
["schema" .= object ["$ref" .= ("#/components/schemas/" <> reqSchema)]]
|
||||
]
|
||||
]
|
||||
, "responses" .= object
|
||||
[ "201" .= object ["description" .= ("Created" :: Text)]
|
||||
[ K.fromText successStatus .= object
|
||||
[ "description" .= ("OK" :: Text)
|
||||
, "content" .= object
|
||||
[ "application/json" .= object
|
||||
["schema" .= object ["$ref" .= ("#/components/schemas/" <> responseSchema)]]
|
||||
]
|
||||
]
|
||||
, "400" .= object ["description" .= ("Invalid request" :: Text)]
|
||||
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
|
||||
, "422" .= object ["description" .= ("Validation error" :: Text)]
|
||||
]
|
||||
]
|
||||
|
||||
postNoBodyOpWithStatusAndParams :: Text -> Text -> Text -> [Value] -> Value
|
||||
postNoBodyOpWithStatusAndParams summaryText responseSchema successStatus params = object
|
||||
[ "summary" .= summaryText
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
, "parameters" .= params
|
||||
, "responses" .= object
|
||||
[ K.fromText successStatus .= object
|
||||
[ "description" .= ("OK" :: Text)
|
||||
, "content" .= object
|
||||
[ "application/json" .= object
|
||||
["schema" .= object ["$ref" .= ("#/components/schemas/" <> responseSchema)]]
|
||||
]
|
||||
]
|
||||
, "400" .= object ["description" .= ("Invalid request" :: Text)]
|
||||
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
|
||||
, "422" .= object ["description" .= ("Validation error" :: Text)]
|
||||
]
|
||||
]
|
||||
|
||||
pathParam :: Text -> Value
|
||||
pathParam name = object
|
||||
[ "name" .= name
|
||||
, "in" .= ("path" :: Text)
|
||||
, "required" .= True
|
||||
, "schema" .= uuidProp
|
||||
]
|
||||
|
||||
publicListPath :: Text -> Value
|
||||
publicListPath schemaName = object
|
||||
[ "get" .= object
|
||||
[ "summary" .= ("List registered " <> schemaName <> " values" :: Text)
|
||||
, "security" .= ([] :: [Value])
|
||||
, "responses" .= object
|
||||
[ "200" .= object ["description" .= ("OK" :: Text)] ]
|
||||
]
|
||||
@@ -266,6 +428,37 @@ pageParams =
|
||||
|
||||
-- Schemas for all resource types
|
||||
|
||||
hubSchema :: Value
|
||||
hubSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "slug" .= strProp
|
||||
, "name" .= strProp
|
||||
, "domain" .= strProp
|
||||
, "hubKind" .= object ["type" .= ("string" :: Text), "enum" .= ["domain" :: Text, "shared"]]
|
||||
, "hubFamily" .= object ["type" .= ("string" :: Text), "enum" .= ["vsm" :: Text]]
|
||||
, "vsmFunction" .= strProp
|
||||
, "vsmSystem" .= object ["type" .= ("string" :: Text), "enum" .= ["1" :: Text, "2", "3", "3*", "4", "5", "environment"]]
|
||||
, "createdAt" .= object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
|
||||
]
|
||||
]
|
||||
|
||||
createHubRequestSchema :: Value
|
||||
createHubRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "required" .= (["slug", "name", "domain"] :: [Text])
|
||||
, "properties" .= object
|
||||
[ "slug" .= strProp
|
||||
, "name" .= strProp
|
||||
, "domain" .= strProp
|
||||
, "hubKind" .= object ["type" .= ("string" :: Text), "enum" .= ["domain" :: Text, "shared"]]
|
||||
, "hubFamily" .= object ["type" .= ("string" :: Text), "enum" .= ["vsm" :: Text]]
|
||||
, "vsmFunction" .= strProp
|
||||
, "vsmSystem" .= object ["type" .= ("string" :: Text), "enum" .= ["1" :: Text, "2", "3", "3*", "4", "5", "environment"]]
|
||||
]
|
||||
]
|
||||
|
||||
widgetSchema :: Value
|
||||
widgetSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
@@ -283,6 +476,135 @@ widgetSchema = object
|
||||
]
|
||||
]
|
||||
|
||||
createWidgetRequestSchema :: Value
|
||||
createWidgetRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "required" .= (["hubId", "name", "widgetType"] :: [Text])
|
||||
, "properties" .= object
|
||||
[ "hubId" .= uuidProp
|
||||
, "name" .= strProp
|
||||
, "widgetType" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]
|
||||
, "capabilityRef" .= strProp
|
||||
, "viewContext" .= strProp
|
||||
, "policyScope" .= object ["$ref" .= ("#/components/schemas/PolicyScope" :: Text)]
|
||||
, "status" .= object ["type" .= ("string" :: Text), "enum" .= ["active" :: Text, "deprecated", "draft"]]
|
||||
, "adapterSpecId" .= uuidProp
|
||||
]
|
||||
]
|
||||
|
||||
manifestSchema :: Value
|
||||
manifestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "hubId" .= uuidProp
|
||||
, "manifestVersion" .= strProp
|
||||
, "declaredWidgetTypes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]]
|
||||
, "declaredEventTypes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/EventType" :: Text)]]
|
||||
, "declaredAnnotationCategories" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/AnnotationCategory" :: Text)]]
|
||||
, "declaredPolicyScopes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/PolicyScope" :: Text)]]
|
||||
, "capabilityDescription" .= strProp
|
||||
, "contact" .= strProp
|
||||
, "status" .= strProp
|
||||
, "activatedAt" .= dtProp
|
||||
, "createdAt" .= dtProp
|
||||
, "updatedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
createManifestRequestSchema :: Value
|
||||
createManifestRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "required" .= (["hubId"] :: [Text])
|
||||
, "properties" .= manifestRequestProperties True
|
||||
]
|
||||
|
||||
updateManifestRequestSchema :: Value
|
||||
updateManifestRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= manifestRequestProperties False
|
||||
]
|
||||
|
||||
manifestRequestProperties :: Bool -> Value
|
||||
manifestRequestProperties includeHubId =
|
||||
object $
|
||||
(if includeHubId then ["hubId" .= uuidProp] else [])
|
||||
++ [ "manifestVersion" .= strProp
|
||||
, "declaredWidgetTypes" .= arrayOfRef "WidgetType"
|
||||
, "declaredEventTypes" .= arrayOfRef "EventType"
|
||||
, "declaredAnnotationCategories" .= arrayOfRef "AnnotationCategory"
|
||||
, "declaredPolicyScopes" .= arrayOfRef "PolicyScope"
|
||||
, "capabilityDescription" .= strProp
|
||||
, "contact" .= strProp
|
||||
]
|
||||
|
||||
apiConsumerSchema :: Value
|
||||
apiConsumerSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "name" .= strProp
|
||||
, "description" .= strProp
|
||||
, "hubCapabilityManifestId" .= uuidProp
|
||||
, "rateLimitPerMinute" .= object ["type" .= ("integer" :: Text)]
|
||||
, "quotaPerDay" .= object ["type" .= ("integer" :: Text)]
|
||||
, "quotaResetsAt" .= dtProp
|
||||
, "isActive" .= object ["type" .= ("boolean" :: Text)]
|
||||
, "createdAt" .= dtProp
|
||||
, "updatedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
createApiConsumerRequestSchema :: Value
|
||||
createApiConsumerRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "required" .= (["name"] :: [Text])
|
||||
, "properties" .= object
|
||||
[ "name" .= strProp
|
||||
, "description" .= strProp
|
||||
, "hubCapabilityManifestId" .= uuidProp
|
||||
, "rateLimitPerMinute" .= object ["type" .= ("integer" :: Text), "minimum" .= (1 :: Int), "default" .= (60 :: Int)]
|
||||
, "quotaPerDay" .= object ["type" .= ("integer" :: Text), "minimum" .= (1 :: Int), "default" .= (10000 :: Int)]
|
||||
]
|
||||
]
|
||||
|
||||
apiKeySchema :: Value
|
||||
apiKeySchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "apiConsumerId" .= uuidProp
|
||||
, "keyPrefix" .= strProp
|
||||
, "scopes" .= strProp
|
||||
, "tokenType" .= strProp
|
||||
, "expiresAt" .= dtProp
|
||||
, "revokedAt" .= dtProp
|
||||
, "lastUsedAt" .= dtProp
|
||||
, "createdAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
createApiKeyRequestSchema :: Value
|
||||
createApiKeyRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "scopes" .= strProp
|
||||
]
|
||||
]
|
||||
|
||||
apiKeyCreatedResponseSchema :: Value
|
||||
apiKeyCreatedResponseSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "apiKey" .= object ["$ref" .= ("#/components/schemas/ApiKey" :: Text)]
|
||||
, "fullKey" .= object
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "description" .= ("Static API key secret. Returned only in this creation response; it is stored hashed and cannot be recovered later." :: Text)
|
||||
]
|
||||
, "displayOnce" .= boolProp
|
||||
]
|
||||
]
|
||||
|
||||
interactionEventSchema :: Value
|
||||
interactionEventSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
@@ -298,6 +620,18 @@ interactionEventSchema = object
|
||||
]
|
||||
]
|
||||
|
||||
createInteractionEventRequestSchema :: Value
|
||||
createInteractionEventRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "required" .= (["widgetId", "eventType"] :: [Text])
|
||||
, "properties" .= object
|
||||
[ "widgetId" .= uuidProp
|
||||
, "eventType" .= object ["$ref" .= ("#/components/schemas/EventType" :: Text)]
|
||||
, "viewContext" .= strProp
|
||||
, "metadata" .= objectProp
|
||||
]
|
||||
]
|
||||
|
||||
annotationSchema :: Value
|
||||
annotationSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
@@ -315,6 +649,17 @@ annotationSchema = object
|
||||
]
|
||||
]
|
||||
|
||||
createAnnotationRequestSchema :: Value
|
||||
createAnnotationRequestSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "required" .= (["widgetId", "category", "body"] :: [Text])
|
||||
, "properties" .= object
|
||||
[ "widgetId" .= uuidProp
|
||||
, "category" .= object ["$ref" .= ("#/components/schemas/AnnotationCategory" :: Text)]
|
||||
, "body" .= strProp
|
||||
]
|
||||
]
|
||||
|
||||
rcSchema :: Value
|
||||
rcSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
@@ -416,12 +761,114 @@ institutionalKnowledgeSchema = object
|
||||
]
|
||||
]
|
||||
|
||||
hubRegistryEntrySchema :: Value
|
||||
hubRegistryEntrySchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "name" .= strProp
|
||||
, "slug" .= strProp
|
||||
, "domain" .= strProp
|
||||
, "hubKind" .= strProp
|
||||
, "hubFamily" .= strProp
|
||||
, "vsmFunction" .= strProp
|
||||
, "vsmSystem" .= strProp
|
||||
, "gaafStatus" .= object ["type" .= ("string" :: Text), "enum" .= ["compliant" :: Text, "draft_only", "no_manifest"]]
|
||||
, "manifest" .= object ["$ref" .= ("#/components/schemas/HubManifestSummary" :: Text)]
|
||||
, "healthScore" .= intProp
|
||||
, "healthAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
hubManifestSummarySchema :: Value
|
||||
hubManifestSummarySchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "manifestVersion" .= strProp
|
||||
, "status" .= strProp
|
||||
, "declaredWidgetTypes" .= arrayOfRef "WidgetType"
|
||||
, "declaredEventTypes" .= arrayOfRef "EventType"
|
||||
, "declaredAnnotationCategories" .= arrayOfRef "AnnotationCategory"
|
||||
, "declaredPolicyScopes" .= arrayOfRef "PolicyScope"
|
||||
, "activatedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
widgetPatternSchema :: Value
|
||||
widgetPatternSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "hubId" .= uuidProp
|
||||
, "name" .= strProp
|
||||
, "description" .= strProp
|
||||
, "widgetType" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]
|
||||
, "isCrossHub" .= boolProp
|
||||
, "isPublished" .= boolProp
|
||||
, "createdAt" .= dtProp
|
||||
, "updatedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
widgetPatternDetailSchema :: Value
|
||||
widgetPatternDetailSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "pattern" .= object ["$ref" .= ("#/components/schemas/WidgetPattern" :: Text)]
|
||||
, "versions" .= object
|
||||
[ "type" .= ("array" :: Text)
|
||||
, "items" .= object ["$ref" .= ("#/components/schemas/WidgetPatternVersion" :: Text)]
|
||||
]
|
||||
, "adopterCount" .= intProp
|
||||
]
|
||||
]
|
||||
|
||||
widgetPatternVersionSchema :: Value
|
||||
widgetPatternVersionSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "versionNumber" .= intProp
|
||||
, "definition" .= objectProp
|
||||
, "changelog" .= strProp
|
||||
, "publishedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
patternAdoptionResponseSchema :: Value
|
||||
patternAdoptionResponseSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "adopted" .= boolProp
|
||||
, "adoptionId" .= uuidProp
|
||||
]
|
||||
]
|
||||
|
||||
uuidProp :: Value
|
||||
uuidProp = object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
|
||||
|
||||
strProp :: Value
|
||||
strProp = object ["type" .= ("string" :: Text)]
|
||||
|
||||
intProp :: Value
|
||||
intProp = object ["type" .= ("integer" :: Text)]
|
||||
|
||||
boolProp :: Value
|
||||
boolProp = object ["type" .= ("boolean" :: Text)]
|
||||
|
||||
objectProp :: Value
|
||||
objectProp = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "additionalProperties" .= True
|
||||
]
|
||||
|
||||
arrayOfRef :: Text -> Value
|
||||
arrayOfRef schemaName = object
|
||||
[ "type" .= ("array" :: Text)
|
||||
, "items" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]
|
||||
]
|
||||
|
||||
dtProp :: Value
|
||||
dtProp = object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ module Web.Controller.Api.V2.Registries where
|
||||
-- GET /api/v2/widget-types
|
||||
-- GET /api/v2/event-types
|
||||
-- GET /api/v2/annotation-categories
|
||||
-- GET /api/v2/policy-scopes
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
@@ -16,24 +17,31 @@ instance Controller ApiV2RegistriesController where
|
||||
action ApiV2ListWidgetTypesAction = do
|
||||
types <- query @WidgetTypeRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #label_
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
renderJson $ map wtToJson types
|
||||
|
||||
action ApiV2ListEventTypesAction = do
|
||||
types <- query @EventTypeRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #label_
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
renderJson $ map etToJson types
|
||||
|
||||
action ApiV2ListAnnotationCategoriesAction = do
|
||||
cats <- query @AnnotationCategoryRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #label_
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
renderJson $ map acToJson cats
|
||||
|
||||
action ApiV2ListPolicyScopesAction = do
|
||||
scopes <- query @PolicyScopeRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
renderJson $ map psToJson scopes
|
||||
|
||||
wtToJson :: WidgetTypeRegistry -> Value
|
||||
wtToJson r = object
|
||||
[ "name" .= r.name
|
||||
@@ -60,3 +68,12 @@ acToJson r = object
|
||||
, "ownerHubId" .= r.ownerHubId
|
||||
, "status" .= r.status
|
||||
]
|
||||
|
||||
psToJson :: PolicyScopeRegistry -> Value
|
||||
psToJson r = object
|
||||
[ "name" .= r.name
|
||||
, "label" .= r.label_
|
||||
, "description" .= r.description
|
||||
, "ownerHubId" .= r.ownerHubId
|
||||
, "status" .= r.status
|
||||
]
|
||||
|
||||
@@ -94,11 +94,39 @@ tsSdkClientClass = T.unlines
|
||||
, " });"
|
||||
, " }"
|
||||
, ""
|
||||
, " async createHub(body: { slug: string; name: string; domain: string; hubKind?: 'domain' | 'shared'; hubFamily?: 'vsm'; vsmFunction?: string; vsmSystem?: '1' | '2' | '3' | '3*' | '4' | '5' | 'environment' }) {"
|
||||
, " return this.fetch('/hubs', 'POST', body).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async createHubCapabilityManifest(body: { hubId: string; manifestVersion?: string; declaredWidgetTypes?: WidgetType[]; declaredEventTypes?: EventType[]; declaredAnnotationCategories?: AnnotationCategory[]; declaredPolicyScopes?: string[]; capabilityDescription?: string; contact?: string }) {"
|
||||
, " return this.fetch('/hub-capability-manifests', 'POST', body).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async updateHubCapabilityManifest(id: string, body: { manifestVersion?: string; declaredWidgetTypes?: WidgetType[]; declaredEventTypes?: EventType[]; declaredAnnotationCategories?: AnnotationCategory[]; declaredPolicyScopes?: string[]; capabilityDescription?: string; contact?: string }) {"
|
||||
, " return this.fetch('/hub-capability-manifests/' + id, 'PATCH', body).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async activateHubCapabilityManifest(id: string) {"
|
||||
, " return this.fetch('/hub-capability-manifests/' + id + '/activate', 'POST').then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async createApiConsumer(body: { name: string; description?: string; hubCapabilityManifestId?: string; rateLimitPerMinute?: number; quotaPerDay?: number }) {"
|
||||
, " return this.fetch('/api-consumers', 'POST', body).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async createApiKey(apiConsumerId: string, body?: { scopes?: string }) {"
|
||||
, " return this.fetch('/api-consumers/' + apiConsumerId + '/api-keys', 'POST', body ?? {}).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async getWidgets(params?: { page?: number; perPage?: number }) {"
|
||||
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
|
||||
, " return this.fetch('/widgets' + q).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async createWidget(body: { hubId: string; name: string; widgetType: WidgetType; capabilityRef?: string; viewContext?: string; policyScope?: string; status?: 'active' | 'deprecated' | 'draft' }) {"
|
||||
, " return this.fetch('/widgets', 'POST', body).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) {"
|
||||
, " const qs = new URLSearchParams();"
|
||||
, " if (params?.widgetId) qs.set('widgetId', params.widgetId);"
|
||||
@@ -149,9 +177,46 @@ pyClientClass = T.unlines
|
||||
, " with urllib.request.urlopen(req) as resp:"
|
||||
, " return json.loads(resp.read())"
|
||||
, ""
|
||||
, " def create_hub(self, slug: str, name: str, domain: str, hub_kind: str = 'domain', hub_family: Optional[str] = None, vsm_function: Optional[str] = None, vsm_system: Optional[str] = None) -> dict:"
|
||||
, " body: dict = {'slug': slug, 'name': name, 'domain': domain, 'hubKind': hub_kind}"
|
||||
, " if hub_family: body['hubFamily'] = hub_family"
|
||||
, " if vsm_function: body['vsmFunction'] = vsm_function"
|
||||
, " if vsm_system: body['vsmSystem'] = vsm_system"
|
||||
, " return self._request('/hubs', 'POST', body)"
|
||||
, ""
|
||||
, " def create_hub_capability_manifest(self, body: dict) -> dict:"
|
||||
, " return self._request('/hub-capability-manifests', 'POST', body)"
|
||||
, ""
|
||||
, " def update_hub_capability_manifest(self, manifest_id: str, body: dict) -> dict:"
|
||||
, " return self._request('/hub-capability-manifests/' + manifest_id, 'PATCH', body)"
|
||||
, ""
|
||||
, " def activate_hub_capability_manifest(self, manifest_id: str) -> dict:"
|
||||
, " return self._request('/hub-capability-manifests/' + manifest_id + '/activate', 'POST')"
|
||||
, ""
|
||||
, " def create_api_consumer(self, name: str, description: Optional[str] = None, hub_capability_manifest_id: Optional[str] = None, rate_limit_per_minute: Optional[int] = None, quota_per_day: Optional[int] = None) -> dict:"
|
||||
, " body: dict = {'name': name}"
|
||||
, " if description: body['description'] = description"
|
||||
, " if hub_capability_manifest_id: body['hubCapabilityManifestId'] = hub_capability_manifest_id"
|
||||
, " if rate_limit_per_minute: body['rateLimitPerMinute'] = rate_limit_per_minute"
|
||||
, " if quota_per_day: body['quotaPerDay'] = quota_per_day"
|
||||
, " return self._request('/api-consumers', 'POST', body)"
|
||||
, ""
|
||||
, " def create_api_key(self, api_consumer_id: str, scopes: Optional[str] = None) -> dict:"
|
||||
, " body: dict = {}"
|
||||
, " if scopes: body['scopes'] = scopes"
|
||||
, " return self._request('/api-consumers/' + api_consumer_id + '/api-keys', 'POST', body)"
|
||||
, ""
|
||||
, " def get_widgets(self, page: int = 1, per_page: int = 50) -> dict:"
|
||||
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
|
||||
, ""
|
||||
, " def create_widget(self, hub_id: str, name: str, widget_type: WidgetType, capability_ref: Optional[str] = None, view_context: Optional[str] = None, policy_scope: Optional[str] = None, status: Optional[str] = None) -> dict:"
|
||||
, " body: dict = {'hubId': hub_id, 'name': name, 'widgetType': str(widget_type)}"
|
||||
, " if capability_ref: body['capabilityRef'] = capability_ref"
|
||||
, " if view_context: body['viewContext'] = view_context"
|
||||
, " if policy_scope: body['policyScope'] = policy_scope"
|
||||
, " if status: body['status'] = status"
|
||||
, " return self._request('/widgets', 'POST', body)"
|
||||
, ""
|
||||
, " def get_interaction_events(self, widget_id: Optional[str] = None, event_type: Optional[EventType] = None) -> dict:"
|
||||
, " qs = urllib.parse.urlencode({k: v for k, v in {'widgetId': widget_id, 'eventType': event_type and str(event_type)}.items() if v})"
|
||||
, " return self._request('/interaction-events' + ('?' + qs if qs else ''))"
|
||||
|
||||
@@ -4,12 +4,31 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=), ToJSON, toJSON)
|
||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
||||
import Data.Aeson (Value, object, (.=))
|
||||
import Network.Wai (requestMethod)
|
||||
import qualified Data.UUID as UUID
|
||||
import Application.Helper.TypeRegistry (validateWidgetType, validatePolicyScope)
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus )
|
||||
|
||||
instance Controller ApiV2WidgetsController where
|
||||
|
||||
action ApiV2IndexWidgetsAction = do
|
||||
case requestMethod ?request of
|
||||
"GET" -> listWidgets
|
||||
"POST" -> createApiWidget
|
||||
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
action ApiV2ShowWidgetAction { widgetId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
widget <- fetch widgetId
|
||||
renderJson (widgetToJson widget)
|
||||
|
||||
action ApiV2CreateWidgetAction = createApiWidget
|
||||
|
||||
listWidgets :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
listWidgets = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let pageOffset = (page - 1) * perPage
|
||||
@@ -21,10 +40,121 @@ instance Controller ApiV2WidgetsController where
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map widgetToJson widgets) page perPage total
|
||||
|
||||
action ApiV2ShowWidgetAction { widgetId } = do
|
||||
createApiWidget :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||
createApiWidget = do
|
||||
_consumer <- requireApiConsumer
|
||||
widget <- fetch widgetId
|
||||
renderJson (widgetToJson widget)
|
||||
let hubIdText = paramOrNothing @Text "hubId"
|
||||
name = paramOrNothing @Text "name"
|
||||
widgetType = paramOrNothing @Text "widgetType"
|
||||
capabilityRef = paramOrNothing @Text "capabilityRef"
|
||||
viewContext = paramOrNothing @Text "viewContext"
|
||||
policyScope = fromMaybe "internal" (nonEmptyText =<< paramOrNothing @Text "policyScope")
|
||||
status = fromMaybe "active" (nonEmptyText =<< paramOrNothing @Text "status")
|
||||
|
||||
let missing = missingWidgetCreateFields
|
||||
[ ("hubId", hubIdText)
|
||||
, ("name", name)
|
||||
, ("widgetType", widgetType)
|
||||
]
|
||||
unless (null missing) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Missing required fields" :: Text)
|
||||
, "missing" .= missing
|
||||
]
|
||||
|
||||
unless (validWidgetStatus status) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Unsupported widget status" :: Text)
|
||||
, "code" .= ("unsupported_widget_status" :: Text)
|
||||
, "value" .= status
|
||||
, "valid" .= validWidgetStatuses
|
||||
]
|
||||
|
||||
let Just hubIdRaw = hubIdText
|
||||
Just nameText = name
|
||||
Just typeText = widgetType
|
||||
|
||||
typeResult <- liftIO $ validateWidgetType typeText
|
||||
case typeResult of
|
||||
Left _ -> respondWithStatus 422 $ object
|
||||
[ "error" .= ("Unregistered widget type" :: Text)
|
||||
, "code" .= ("unregistered_widget_type" :: Text)
|
||||
, "value" .= typeText
|
||||
, "registry" .= ("/api/v2/widget-types" :: Text)
|
||||
]
|
||||
Right () -> pure ()
|
||||
|
||||
scopeResult <- liftIO $ validatePolicyScope policyScope
|
||||
case scopeResult of
|
||||
Left _ -> respondWithStatus 422 $ object
|
||||
[ "error" .= ("Unregistered policy scope" :: Text)
|
||||
, "code" .= ("unregistered_policy_scope" :: Text)
|
||||
, "value" .= policyScope
|
||||
, "registry" .= ("/api/v2/policy-scopes" :: Text)
|
||||
]
|
||||
Right () -> pure ()
|
||||
|
||||
adapterSpecId <- parseOptionalAdapterSpecId
|
||||
|
||||
case UUID.fromText hubIdRaw of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("hubId must be a valid UUID" :: Text)]
|
||||
Just rawId -> do
|
||||
let hubId = Id rawId :: Id Hub
|
||||
mHub <- fetchOneOrNothing hubId
|
||||
case mHub of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("Hub not found" :: Text)]
|
||||
Just _hub -> do
|
||||
widget <- newRecord @Widget
|
||||
|> set #hubId hubId
|
||||
|> set #name nameText
|
||||
|> set #widgetType typeText
|
||||
|> set #capabilityRef capabilityRef
|
||||
|> set #viewContext viewContext
|
||||
|> set #policyScope policyScope
|
||||
|> set #status status
|
||||
|> set #adapterSpecId adapterSpecId
|
||||
|> createRecord
|
||||
_version <- createInitialWidgetVersion widget
|
||||
respondWithStatus 201 (widgetToJson widget)
|
||||
|
||||
parseOptionalAdapterSpecId :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO (Maybe (Id WidgetAdapterSpec))
|
||||
parseOptionalAdapterSpecId =
|
||||
case paramOrNothing @Text "adapterSpecId" of
|
||||
Nothing -> pure Nothing
|
||||
Just "" -> pure Nothing
|
||||
Just adapterSpecRaw ->
|
||||
case UUID.fromText adapterSpecRaw of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("adapterSpecId must be a valid UUID" :: Text)]
|
||||
Just rawId -> do
|
||||
let adapterSpecId = Id rawId :: Id WidgetAdapterSpec
|
||||
mAdapterSpec <- fetchOneOrNothing adapterSpecId
|
||||
case mAdapterSpec of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("Widget adapter spec not found" :: Text)]
|
||||
Just _ -> pure (Just adapterSpecId)
|
||||
|
||||
createInitialWidgetVersion :: (?modelContext :: ModelContext) => Widget -> IO WidgetVersion
|
||||
createInitialWidgetVersion widget =
|
||||
newRecord @WidgetVersion
|
||||
|> set #widgetId widget.id
|
||||
|> set #version 1
|
||||
|> set #schemaSnapshot (widgetVersionSnapshot widget)
|
||||
|> createRecord
|
||||
|
||||
widgetVersionSnapshot :: Widget -> Value
|
||||
widgetVersionSnapshot widget = object
|
||||
[ "name" .= widget.name
|
||||
, "widget_type" .= widget.widgetType
|
||||
, "hub_id" .= widget.hubId
|
||||
, "capability_ref" .= widget.capabilityRef
|
||||
, "view_context" .= widget.viewContext
|
||||
, "policy_scope" .= widget.policyScope
|
||||
, "status" .= widget.status
|
||||
, "version" .= widget.version
|
||||
]
|
||||
|
||||
widgetToJson :: Widget -> Value
|
||||
widgetToJson w = object
|
||||
@@ -39,3 +169,17 @@ widgetToJson w = object
|
||||
, "version" .= w.version
|
||||
, "createdAt" .= w.createdAt
|
||||
]
|
||||
|
||||
validWidgetStatuses :: [Text]
|
||||
validWidgetStatuses = ["active", "deprecated", "draft"]
|
||||
|
||||
validWidgetStatus :: Text -> Bool
|
||||
validWidgetStatus status = status `elem` validWidgetStatuses
|
||||
|
||||
missingWidgetCreateFields :: [(Text, Maybe Text)] -> [Text]
|
||||
missingWidgetCreateFields fields =
|
||||
[ name | (name, value) <- fields, maybe True (== "") value ]
|
||||
|
||||
nonEmptyText :: Text -> Maybe Text
|
||||
nonEmptyText "" = Nothing
|
||||
nonEmptyText value = Just value
|
||||
|
||||
@@ -16,7 +16,7 @@ instance Controller TypeRegistriesController where
|
||||
|
||||
action WidgetTypeRegistryAction = do
|
||||
entries <- query @WidgetTypeRegistry
|
||||
|> orderByAsc #label_
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
hubs <- query @Hub |> fetch
|
||||
render WidgetTypesView { entries, hubs }
|
||||
@@ -83,7 +83,7 @@ instance Controller TypeRegistriesController where
|
||||
|
||||
action EventTypeRegistryAction = do
|
||||
entries <- query @EventTypeRegistry
|
||||
|> orderByAsc #label_
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
hubs <- query @Hub |> fetch
|
||||
render EventTypesView { entries, hubs }
|
||||
@@ -149,7 +149,7 @@ instance Controller TypeRegistriesController where
|
||||
|
||||
action AnnotationCategoryRegistryAction = do
|
||||
entries <- query @AnnotationCategoryRegistry
|
||||
|> orderByAsc #label_
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
hubs <- query @Hub |> fetch
|
||||
render AnnotationCategoriesView { entries, hubs }
|
||||
@@ -215,7 +215,7 @@ instance Controller TypeRegistriesController where
|
||||
|
||||
action PolicyScopeRegistryAction = do
|
||||
entries <- query @PolicyScopeRegistry
|
||||
|> orderByAsc #label_
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
hubs <- query @Hub |> fetch
|
||||
render PolicyScopesView { entries, hubs }
|
||||
|
||||
@@ -48,6 +48,9 @@ import Web.Controller.Api.V2.Registries ()
|
||||
import Web.Controller.Api.V2.OpenApi ()
|
||||
import Web.Controller.Api.V2.Token ()
|
||||
import Web.Controller.Api.V2.Sdk ()
|
||||
import Web.Controller.Api.V2.Hubs ()
|
||||
import Web.Controller.Api.V2.HubCapabilityManifests ()
|
||||
import Web.Controller.Api.V2.ApiConsumers ()
|
||||
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
|
||||
import Web.Controller.HubRegistry ()
|
||||
import Web.Controller.WidgetPatterns ()
|
||||
@@ -116,6 +119,9 @@ instance FrontController WebApplication where
|
||||
, parseRoute @ApiV2OpenApiController
|
||||
, parseRoute @ApiV2TokenController
|
||||
, parseRoute @ApiV2SdkController
|
||||
, parseRoute @ApiV2HubsController
|
||||
, parseRoute @ApiV2HubCapabilityManifestsController
|
||||
, parseRoute @ApiV2ApiConsumersController
|
||||
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
|
||||
, parseRoute @HubRegistryController
|
||||
, parseRoute @WidgetPatternsController
|
||||
@@ -147,7 +153,19 @@ instance InitControllerContext WebApplication where
|
||||
initAuthentication @User
|
||||
|
||||
defaultLayout :: (?context :: ControllerContext, ?request :: Request) => Layout
|
||||
defaultLayout inner = [hsx|
|
||||
defaultLayout inner =
|
||||
let authWidget :: Html
|
||||
authWidget = case currentUserOrNothing @User of
|
||||
Just _ -> [hsx|
|
||||
<form method="POST" action={DeleteSessionAction} style="display:inline">
|
||||
<input type="hidden" name="_method" value="DELETE" />
|
||||
<button type="submit" class="text-sm text-gray-500 hover:text-gray-700 bg-transparent border-0 p-0 cursor-pointer">Sign out</button>
|
||||
</form>
|
||||
|]
|
||||
Nothing -> [hsx|
|
||||
<a href={NewSessionAction} class="text-sm text-gray-500 hover:text-gray-900">Sign in</a>
|
||||
|]
|
||||
in [hsx|
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -160,44 +178,59 @@ defaultLayout inner = [hsx|
|
||||
<script src="/vendor/ihp-auto-refresh.js"></script>
|
||||
<script src="/js/ihf-annotation-launcher.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900">
|
||||
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||
<body class="bg-gray-50 text-gray-900" style="min-height:100vh;display:flex;flex-direction:column">
|
||||
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center">
|
||||
<a href={LandingAction} class="font-semibold text-indigo-600">inter-hub</a>
|
||||
<a href={CapabilitiesAction} class="text-sm text-gray-600 hover:text-gray-900">About</a>
|
||||
<a href={TutorialAction} class="text-sm text-gray-600 hover:text-gray-900">Tutorial</a>
|
||||
<a href={ExtensionGuideAction} class="text-sm text-gray-600 hover:text-gray-900">Extend</a>
|
||||
<div class="ml-auto flex items-center" style="gap:2rem">
|
||||
<div class="flex items-center" style="gap:1.75rem">
|
||||
<a href={CapabilitiesAction} class="text-sm text-gray-500 hover:text-gray-900">About</a>
|
||||
<a href={TutorialAction} class="text-sm text-gray-500 hover:text-gray-900">Tutorial</a>
|
||||
<a href={ExtensionGuideAction} class="text-sm text-gray-500 hover:text-gray-900">Extend</a>
|
||||
</div>
|
||||
<span class="text-gray-200">|</span>
|
||||
<a href={HubsAction} class="text-sm text-gray-600 hover:text-gray-900">Hubs</a>
|
||||
<a href={WidgetsAction} class="text-sm text-gray-600 hover:text-gray-900">Widgets</a>
|
||||
<a href={RequirementCandidatesAction} class="text-sm text-gray-600 hover:text-gray-900">Candidates</a>
|
||||
<a href={RequirementsAction} class="text-sm text-gray-600 hover:text-gray-900">Requirements</a>
|
||||
<a href={DecisionRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Decisions</a>
|
||||
<a href={DeploymentRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Deployments</a>
|
||||
<a href={AgentProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Agent</a>
|
||||
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-600 hover:text-gray-900">Adapters</a>
|
||||
<a href={CrossHubPropagationsAction} class="text-sm text-gray-600 hover:text-gray-900">Propagations</a>
|
||||
<a href={OperationalReviewBoardAction} class="text-sm text-gray-600 hover:text-gray-900">Ops Review</a>
|
||||
<a href={FederatedGovernanceDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Federation</a>
|
||||
<a href={FederatedPolicyOverlaysAction} class="text-sm text-gray-600 hover:text-gray-900">Policies</a>
|
||||
<a href={ArchiveRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Archive</a>
|
||||
<a href={WidgetTypeRegistryAction} class="text-sm text-gray-600 hover:text-gray-900">Registries</a>
|
||||
<a href={HubCapabilityManifestsAction} class="text-sm text-gray-600 hover:text-gray-900">Extensions</a>
|
||||
<a href={ApiConsumersAction} class="text-sm text-gray-600 hover:text-gray-900">API</a>
|
||||
<a href={ShowApiDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">API Dashboard</a>
|
||||
<a href={HubRegistryAction} class="text-sm text-gray-600 hover:text-gray-900">Hub Registry</a>
|
||||
<a href={MarketplaceDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Marketplace</a>
|
||||
<a href={AgentRegistrationsAction} class="text-sm text-gray-600 hover:text-gray-900">Agents</a>
|
||||
<a href={ModelRoutingPoliciesAction} class="text-sm text-gray-600 hover:text-gray-900">Routing</a>
|
||||
<a href={CollectiveProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Collective</a>
|
||||
<a href={AiGovernancePoliciesAction} class="text-sm text-gray-600 hover:text-gray-900">AI Gov</a>
|
||||
<a href={LearningDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Learning</a>
|
||||
<div class="ml-auto">
|
||||
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
|
||||
{authWidget}
|
||||
</div>
|
||||
</nav>
|
||||
<main class="max-w-5xl mx-auto px-6 py-8">
|
||||
<div class="flex" style="flex:1">
|
||||
<aside class="w-48 bg-white border-r border-gray-200 flex-shrink-0 overflow-y-auto">
|
||||
<nav class="px-3 py-4 space-y-0.5">
|
||||
<a href={HubsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Hubs</a>
|
||||
<a href={WidgetsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Widgets</a>
|
||||
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Governance</div>
|
||||
<a href={RequirementCandidatesAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Candidates</a>
|
||||
<a href={RequirementsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Requirements</a>
|
||||
<a href={DecisionRecordsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Decisions</a>
|
||||
<a href={DeploymentRecordsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Deployments</a>
|
||||
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Intelligence</div>
|
||||
<a href={AgentProposalsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Agent Proposals</a>
|
||||
<a href={AgentRegistrationsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Agents</a>
|
||||
<a href={ModelRoutingPoliciesAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Routing</a>
|
||||
<a href={CollectiveProposalsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Collective</a>
|
||||
<a href={AiGovernancePoliciesAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">AI Gov</a>
|
||||
<a href={LearningDashboardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Learning</a>
|
||||
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Platform</div>
|
||||
<a href={WidgetAdapterSpecsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Adapters</a>
|
||||
<a href={CrossHubPropagationsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Propagations</a>
|
||||
<a href={OperationalReviewBoardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Ops Review</a>
|
||||
<a href={FederatedGovernanceDashboardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Federation</a>
|
||||
<a href={FederatedPolicyOverlaysAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Policies</a>
|
||||
<a href={ArchiveRecordsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Archive</a>
|
||||
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Registry</div>
|
||||
<a href={WidgetTypeRegistryAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Registries</a>
|
||||
<a href={HubCapabilityManifestsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Extensions</a>
|
||||
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">API & Market</div>
|
||||
<a href={ApiConsumersAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">API Consumers</a>
|
||||
<a href={ShowApiDashboardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">API Dashboard</a>
|
||||
<a href={HubRegistryAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Hub Registry</a>
|
||||
<a href={MarketplaceDashboardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Marketplace</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="px-8 py-8 overflow-y-auto" style="flex:1">
|
||||
<div class="max-w-5xl">
|
||||
{inner}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|]
|
||||
|
||||
@@ -89,6 +89,7 @@ instance CanRoute ApiV2WidgetsController where
|
||||
instance HasPath ApiV2WidgetsController where
|
||||
pathTo ApiV2IndexWidgetsAction = "/api/v2/widgets"
|
||||
pathTo ApiV2ShowWidgetAction { widgetId } = "/api/v2/widgets/" <> tshow widgetId
|
||||
pathTo ApiV2CreateWidgetAction = "/api/v2/widgets"
|
||||
|
||||
instance CanRoute ApiV2InteractionEventsController where
|
||||
parseRoute' = do
|
||||
@@ -177,12 +178,14 @@ instance CanRoute ApiV2RegistriesController where
|
||||
[ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction
|
||||
, do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction
|
||||
, do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction
|
||||
, do _ <- string "policy-scopes"; endOfInput; pure ApiV2ListPolicyScopesAction
|
||||
]
|
||||
|
||||
instance HasPath ApiV2RegistriesController where
|
||||
pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types"
|
||||
pathTo ApiV2ListEventTypesAction = "/api/v2/event-types"
|
||||
pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories"
|
||||
pathTo ApiV2ListPolicyScopesAction = "/api/v2/policy-scopes"
|
||||
|
||||
instance CanRoute ApiV2OpenApiController where
|
||||
parseRoute' = do
|
||||
@@ -242,6 +245,61 @@ instance HasPath ApiV2HubRegistryController where
|
||||
pathTo ApiV2IndexHubRegistryAction = "/api/v2/hub-registry"
|
||||
pathTo ApiV2ShowHubRegistryAction { hubId } = "/api/v2/hub-registry/" <> tshow hubId
|
||||
|
||||
instance CanRoute ApiV2HubsController where
|
||||
parseRoute' = do
|
||||
_ <- string "/api/v2/hubs"
|
||||
choice
|
||||
[ do endOfInput; pure ApiV2IndexHubsAction
|
||||
, do _ <- string "/"; hId <- parseUUID; endOfInput
|
||||
pure ApiV2ShowHubAction { hubId = Id hId }
|
||||
]
|
||||
|
||||
instance HasPath ApiV2HubsController where
|
||||
pathTo ApiV2IndexHubsAction = "/api/v2/hubs"
|
||||
pathTo ApiV2ShowHubAction { hubId } = "/api/v2/hubs/" <> tshow hubId
|
||||
pathTo ApiV2CreateHubAction = "/api/v2/hubs"
|
||||
|
||||
instance CanRoute ApiV2HubCapabilityManifestsController where
|
||||
parseRoute' = do
|
||||
_ <- string "/api/v2/hub-capability-manifests"
|
||||
choice
|
||||
[ do endOfInput; pure ApiV2IndexHubCapabilityManifestsAction
|
||||
, do _ <- string "/"; mId <- parseUUID
|
||||
choice
|
||||
[ do _ <- string "/activate"; endOfInput
|
||||
pure ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId = Id mId }
|
||||
, do endOfInput
|
||||
pure ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId = Id mId }
|
||||
]
|
||||
]
|
||||
|
||||
instance HasPath ApiV2HubCapabilityManifestsController where
|
||||
pathTo ApiV2IndexHubCapabilityManifestsAction = "/api/v2/hub-capability-manifests"
|
||||
pathTo ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
|
||||
pathTo ApiV2CreateHubCapabilityManifestAction = "/api/v2/hub-capability-manifests"
|
||||
pathTo ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
|
||||
pathTo ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId <> "/activate"
|
||||
|
||||
instance CanRoute ApiV2ApiConsumersController where
|
||||
parseRoute' = do
|
||||
_ <- string "/api/v2/api-consumers"
|
||||
choice
|
||||
[ do endOfInput; pure ApiV2IndexApiConsumersAction
|
||||
, do _ <- string "/"; cId <- parseUUID
|
||||
choice
|
||||
[ do _ <- string "/api-keys"; endOfInput
|
||||
pure ApiV2CreateApiConsumerKeyAction { apiConsumerId = Id cId }
|
||||
, do endOfInput
|
||||
pure ApiV2ShowApiConsumerAction { apiConsumerId = Id cId }
|
||||
]
|
||||
]
|
||||
|
||||
instance HasPath ApiV2ApiConsumersController where
|
||||
pathTo ApiV2IndexApiConsumersAction = "/api/v2/api-consumers"
|
||||
pathTo ApiV2ShowApiConsumerAction { apiConsumerId } = "/api/v2/api-consumers/" <> tshow apiConsumerId
|
||||
pathTo ApiV2CreateApiConsumerAction = "/api/v2/api-consumers"
|
||||
pathTo ApiV2CreateApiConsumerKeyAction { apiConsumerId } = "/api/v2/api-consumers/" <> tshow apiConsumerId <> "/api-keys"
|
||||
|
||||
instance CanRoute ApiV2WidgetPatternsController where
|
||||
parseRoute' = do
|
||||
_ <- string "/api/v2/widget-patterns"
|
||||
|
||||
23
Web/Types.hs
23
Web/Types.hs
@@ -285,6 +285,7 @@ data ApiDashboardController
|
||||
data ApiV2WidgetsController
|
||||
= ApiV2IndexWidgetsAction
|
||||
| ApiV2ShowWidgetAction { widgetId :: !(Id Widget) }
|
||||
| ApiV2CreateWidgetAction
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2InteractionEventsController
|
||||
@@ -323,6 +324,7 @@ data ApiV2RegistriesController
|
||||
= ApiV2ListWidgetTypesAction
|
||||
| ApiV2ListEventTypesAction
|
||||
| ApiV2ListAnnotationCategoriesAction
|
||||
| ApiV2ListPolicyScopesAction
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2OpenApiController
|
||||
@@ -400,6 +402,27 @@ data ApiV2HubRegistryController
|
||||
| ApiV2ShowHubRegistryAction { hubId :: !(Id Hub) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2HubsController
|
||||
= ApiV2IndexHubsAction
|
||||
| ApiV2ShowHubAction { hubId :: !(Id Hub) }
|
||||
| ApiV2CreateHubAction
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2HubCapabilityManifestsController
|
||||
= ApiV2IndexHubCapabilityManifestsAction
|
||||
| ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
|
||||
| ApiV2CreateHubCapabilityManifestAction
|
||||
| ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
|
||||
| ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2ApiConsumersController
|
||||
= ApiV2IndexApiConsumersAction
|
||||
| ApiV2ShowApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) }
|
||||
| ApiV2CreateApiConsumerAction
|
||||
| ApiV2CreateApiConsumerKeyAction { apiConsumerId :: !(Id ApiConsumer) }
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2WidgetPatternsController
|
||||
= ApiV2IndexWidgetPatternsAction
|
||||
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }
|
||||
|
||||
@@ -53,6 +53,7 @@ renderRow row@HubRegistryRow { hub, mManifest, mLatestSnapshot } =
|
||||
{hub.name}
|
||||
</a>
|
||||
<span class="text-xs text-gray-400 font-mono">{hub.hubKind}</span>
|
||||
{classificationBadge hub}
|
||||
{gaafBadge gs}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
@@ -74,6 +75,17 @@ gaafBadge GaafDraftOnly =
|
||||
gaafBadge GaafNoManifest =
|
||||
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">no manifest</span>|]
|
||||
|
||||
classificationBadge :: Hub -> Html
|
||||
classificationBadge hub =
|
||||
case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of
|
||||
(Just "vsm", Just functionName, Just systemName) ->
|
||||
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">VSM {functionName} / {vsmSystemLabel systemName}</span>|]
|
||||
_ -> mempty
|
||||
|
||||
vsmSystemLabel :: Text -> Text
|
||||
vsmSystemLabel "environment" = "Environment"
|
||||
vsmSystemLabel systemName = "System " <> systemName
|
||||
|
||||
healthScoreBadge :: Int -> Html
|
||||
healthScoreBadge s =
|
||||
let cls :: Text
|
||||
|
||||
@@ -26,6 +26,7 @@ instance View IndexView where
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Slug</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Domain</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Kind</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Family</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -41,6 +42,17 @@ kindBadge "framework" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-purple-
|
||||
kindBadge "shared" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-teal-100 text-teal-800">shared</span>|]
|
||||
kindBadge _ = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800">domain</span>|]
|
||||
|
||||
classificationBadge :: Hub -> Html
|
||||
classificationBadge hub =
|
||||
case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of
|
||||
(Just "vsm", Just functionName, Just systemName) ->
|
||||
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">VSM {functionName} / {vsmSystemLabel systemName}</span>|]
|
||||
_ -> [hsx|<span class="text-xs text-gray-400">-</span>|]
|
||||
|
||||
vsmSystemLabel :: Text -> Text
|
||||
vsmSystemLabel "environment" = "Environment"
|
||||
vsmSystemLabel systemName = "System " <> systemName
|
||||
|
||||
renderHub :: Hub -> Html
|
||||
renderHub hub = [hsx|
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
@@ -53,6 +65,7 @@ renderHub hub = [hsx|
|
||||
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{hub.slug}</td>
|
||||
<td class="px-4 py-3 text-gray-500">{hub.domain}</td>
|
||||
<td class="px-4 py-3">{kindBadge hub.hubKind}</td>
|
||||
<td class="px-4 py-3">{classificationBadge hub}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href={EditHubAction (hub.id)}
|
||||
class="text-gray-500 hover:text-gray-700 text-xs mr-3">Edit</a>
|
||||
|
||||
@@ -27,6 +27,7 @@ instance View ShowView where
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold">{hub.name}</h1>
|
||||
{kindBadge hub.hubKind}
|
||||
{classificationBadge hub}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<span class="font-mono bg-gray-100 px-1 rounded">{hub.slug}</span>
|
||||
@@ -223,6 +224,17 @@ kindBadge "framework" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-purple-
|
||||
kindBadge "shared" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-teal-100 text-teal-800">shared</span>|]
|
||||
kindBadge _ = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800">domain</span>|]
|
||||
|
||||
classificationBadge :: Hub -> Html
|
||||
classificationBadge hub =
|
||||
case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of
|
||||
(Just "vsm", Just functionName, Just systemName) ->
|
||||
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">VSM {functionName} / {vsmSystemLabel systemName}</span>|]
|
||||
_ -> mempty
|
||||
|
||||
vsmSystemLabel :: Text -> Text
|
||||
vsmSystemLabel "environment" = "Environment"
|
||||
vsmSystemLabel systemName = "System " <> systemName
|
||||
|
||||
maybeText :: Maybe Text -> [Text]
|
||||
maybeText Nothing = []
|
||||
maybeText (Just t) = [t]
|
||||
|
||||
@@ -115,7 +115,7 @@ typeForm entry hubs isNew = [hsx|
|
||||
{renderNameField isNew entry.name}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
||||
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
|
||||
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>
|
||||
|
||||
@@ -115,7 +115,7 @@ typeForm entry hubs isNew = [hsx|
|
||||
{renderNameField isNew entry.name}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
||||
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
|
||||
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>
|
||||
|
||||
@@ -115,7 +115,7 @@ typeForm entry hubs isNew = [hsx|
|
||||
{renderNameField isNew entry.name}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
||||
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
|
||||
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>
|
||||
|
||||
@@ -116,7 +116,7 @@ typeForm entry hubs isNew = [hsx|
|
||||
{renderNameField isNew entry.name}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
||||
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
|
||||
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>
|
||||
|
||||
13
app.toml
Normal file
13
app.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[app]
|
||||
name = "inter-hub"
|
||||
slug = "inter-hub"
|
||||
kind = "native"
|
||||
registry = "gitea.coulomb.social/coulomb/inter-hub"
|
||||
|
||||
[deploy]
|
||||
release = "inter-hub"
|
||||
namespace = "inter-hub"
|
||||
chart = "railiance-apps/charts/inter-hub"
|
||||
values = "railiance-apps/helm/inter-hub-values.yaml"
|
||||
runtime_secret = "inter-hub-env"
|
||||
public_url = "https://hub.coulomb.social"
|
||||
@@ -126,14 +126,31 @@ Domain hubs may register additional event types via `HubCapabilityManifest`.
|
||||
|
||||
## Phase 9 Extension: `/api/v2/` (IHUB-WP-0010)
|
||||
|
||||
The v2 API supersedes per-hub Bearer tokens with OAuth 2.0 client credentials.
|
||||
The v2 API supports authenticated Bearer access with static API keys and, where
|
||||
configured, OAuth 2.0 client credentials.
|
||||
|
||||
**OpenAPI spec:** `/api/v2/openapi.json` (live-generated; `widget_type`,
|
||||
`event_type`, and `category` fields carry `enum` arrays from the type registries)
|
||||
|
||||
`GET /api/v2/hubs` and the vocabulary registry list endpoints are public
|
||||
discovery surfaces. Mutating bootstrap operations still require Bearer access.
|
||||
|
||||
**New endpoints in v2:**
|
||||
- `POST /api/v2/token` — OAuth 2.0 client credentials token exchange
|
||||
- `GET /api/v2/hubs` / `POST /api/v2/hubs` — list or create hubs, including
|
||||
first-class VSM hub metadata
|
||||
- `GET /api/v2/hub-capability-manifests` / `POST /api/v2/hub-capability-manifests`
|
||||
— list or create hub capability manifests
|
||||
- `PATCH /api/v2/hub-capability-manifests/{id}` — update draft manifest
|
||||
vocabulary and metadata
|
||||
- `POST /api/v2/hub-capability-manifests/{id}/activate` — activate a draft
|
||||
manifest and register its declared vocabulary
|
||||
- `GET /api/v2/api-consumers` / `POST /api/v2/api-consumers` — list or create
|
||||
API consumers
|
||||
- `POST /api/v2/api-consumers/{id}/api-keys` — create a static API key; the
|
||||
raw `fullKey` is returned exactly once
|
||||
- `GET /api/v2/widgets` — paginated widget listing
|
||||
- `POST /api/v2/widgets` — create a widget
|
||||
- `GET /api/v2/interaction-events` — paginated event listing
|
||||
- `POST /api/v2/interaction-events` — submit event (registry-validated)
|
||||
- `GET /api/v2/annotations` — paginated annotation listing
|
||||
|
||||
6
deploy/helm/inter-hub/Chart.yaml
Normal file
6
deploy/helm/inter-hub/Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: inter-hub
|
||||
description: Interaction Hub Framework — reference implementation
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.2.0-alpha.1"
|
||||
43
deploy/helm/inter-hub/templates/deployment.yaml
Normal file
43
deploy/helm/inter-hub/templates/deployment.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
spec:
|
||||
containers:
|
||||
- name: inter-hub
|
||||
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ .Values.envFrom.secretRef }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
30
deploy/helm/inter-hub/templates/ingress.yaml
Normal file
30
deploy/helm/inter-hub/templates/ingress.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
annotations:
|
||||
{{- toYaml .Values.ingress.annotations | nindent 4 }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.ingress.host }}
|
||||
secretName: {{ .Release.Name }}-tls
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}
|
||||
port:
|
||||
number: {{ .Values.service.port }}
|
||||
{{- end }}
|
||||
15
deploy/helm/inter-hub/templates/service.yaml
Normal file
15
deploy/helm/inter-hub/templates/service.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
selector:
|
||||
app: {{ .Release.Name }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
33
deploy/helm/inter-hub/values.yaml
Normal file
33
deploy/helm/inter-hub/values.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: gitea.coulomb.social/coulomb/inter-hub
|
||||
tag: "latest"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8000
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: traefik
|
||||
host: hub.coulomb.social
|
||||
tls: true
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
|
||||
envFrom:
|
||||
secretRef: inter-hub-env
|
||||
|
||||
runMigrations: false
|
||||
279
deploy/railiance/RUNBOOK.md
Normal file
279
deploy/railiance/RUNBOOK.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# inter-hub Production Deploy Runbook
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Deployment cluster:** COULOMBCORE K3s (`92.205.130.254`) as observed from
|
||||
the haskelseed runner kube context on 2026-06-14.
|
||||
- **Stale public DNS host:** `hub.coulomb.social` still resolved to
|
||||
`92.205.62.239` on 2026-06-14, which served the older API surface.
|
||||
- **Namespace:** `inter-hub`
|
||||
- **Image registry:** `gitea.coulomb.social/coulomb/inter-hub:<sha>`
|
||||
- **Database:** CloudNativePG cluster `net-kingdom-pg` in `databases` namespace
|
||||
- RW endpoint: `net-kingdom-pg-rw.databases.svc.cluster.local:5432`
|
||||
- Database: `interhub`, User: `interhub`
|
||||
- **Ingress:** Traefik → `hub.coulomb.social` (TLS via letsencrypt-prod)
|
||||
- **Secrets:** `inter-hub-env` Secret in `inter-hub` namespace
|
||||
- **App handoff:** `app.toml` points Railiance operators to
|
||||
`railiance-apps/charts/inter-hub` with values from
|
||||
`railiance-apps/helm/inter-hub-values.yaml`
|
||||
|
||||
## Public DNS Gate
|
||||
|
||||
The app deployment can be healthy while public smoke tests still fail if DNS
|
||||
points `hub.coulomb.social` at the stale host. On 2026-06-14:
|
||||
|
||||
- Kubernetes reported image `gitea.coulomb.social/coulomb/inter-hub:6455902`
|
||||
ready in namespace `inter-hub` on node `92.205.130.254`.
|
||||
- An in-cluster probe to `http://inter-hub:8000/api/v2/hubs` returned `401`.
|
||||
- Forcing public TLS to the cluster ingress also returned `401`:
|
||||
`curl --resolve hub.coulomb.social:443:92.205.130.254 https://hub.coulomb.social/api/v2/hubs`.
|
||||
- Normal DNS resolved `hub.coulomb.social` to `92.205.62.239`, where
|
||||
`/api/v2/hubs` returned `404` and OpenAPI lacked the bootstrap paths.
|
||||
|
||||
Before treating a deploy as failed, compare DNS and forced-ingress probes:
|
||||
|
||||
```bash
|
||||
getent ahosts hub.coulomb.social
|
||||
curl -s -o /dev/null -w "%{http_code}" https://hub.coulomb.social/api/v2/hubs
|
||||
curl --resolve hub.coulomb.social:443:92.205.130.254 \
|
||||
-s -o /dev/null -w "%{http_code}" \
|
||||
https://hub.coulomb.social/api/v2/hubs
|
||||
```
|
||||
|
||||
The public bootstrap gate passes when the DNS A record for
|
||||
`hub.coulomb.social` points at the active ingress IP (`92.205.130.254`) or the
|
||||
workflow kubeconfig is intentionally rotated to deploy to the cluster behind the
|
||||
current DNS target.
|
||||
|
||||
## Deployment
|
||||
|
||||
Normal deployment is handled by Gitea Actions on push to `main`:
|
||||
|
||||
- runner labels: `self-hosted`, `haskelseed`
|
||||
- build: `nix build .#docker`
|
||||
- publish: `gitea.coulomb.social/coulomb/inter-hub:<short-sha>` and `latest`
|
||||
- deploy: `helm upgrade --install inter-hub deploy/helm/inter-hub ...`
|
||||
- smoke: public landing page and v2 auth gate
|
||||
|
||||
Manual deployment from this repo:
|
||||
|
||||
```bash
|
||||
helm upgrade --install inter-hub deploy/helm/inter-hub \
|
||||
--namespace inter-hub --create-namespace \
|
||||
--set image.tag=<short-sha> \
|
||||
--wait --timeout 5m
|
||||
```
|
||||
|
||||
Manual deployment through the Railiance app handoff chart:
|
||||
|
||||
```bash
|
||||
helm upgrade --install inter-hub /home/worsch/railiance-apps/charts/inter-hub \
|
||||
--namespace inter-hub --create-namespace \
|
||||
-f /home/worsch/railiance-apps/helm/inter-hub-values.yaml \
|
||||
--set image.tag=<short-sha> \
|
||||
--wait --timeout 5m
|
||||
```
|
||||
|
||||
## Image Build (on haskelseed)
|
||||
|
||||
```bash
|
||||
ssh root@192.168.178.135
|
||||
cd /root/inter-hub
|
||||
# Build:
|
||||
nix build .#docker --log-format raw > /tmp/build.log 2>&1
|
||||
|
||||
# Push:
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
TOKEN=$(curl -fsS \
|
||||
"https://gitea.coulomb.social/v2/token?service=container_registry&scope=repository:coulomb/inter-hub:push,pull" \
|
||||
-u "tegwick:<REGISTRY_TOKEN>" | awk -F'"' '/token/{print $4}')
|
||||
skopeo copy --insecure-policy \
|
||||
--dest-registry-token "$TOKEN" \
|
||||
docker-archive:result \
|
||||
docker://gitea.coulomb.social/coulomb/inter-hub:$SHA
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Haskelseed is a build/deploy runner, not the production app host.
|
||||
- The IHP Nix Docker image may not have `/bin/sh`. Prefer Kubernetes-native
|
||||
checks from other pods or the database pod when possible.
|
||||
|
||||
## Gitea Registry Credentials
|
||||
|
||||
The deploy workflow uses the repository Actions secret `REGISTRY_TOKEN` to
|
||||
request a short-lived registry bearer token from
|
||||
`https://gitea.coulomb.social/v2/token`.
|
||||
|
||||
If publishing starts failing with an authentication error:
|
||||
1. Generate or rotate a Gitea token with package write access.
|
||||
2. Update the `REGISTRY_TOKEN` Actions secret for `coulomb/inter-hub`.
|
||||
3. Rerun the workflow or push a non-production test commit.
|
||||
|
||||
Do not print token values in logs, State Hub, or commits.
|
||||
|
||||
## Runtime Secret Source
|
||||
|
||||
The live deployment currently consumes the Kubernetes Secret
|
||||
`inter-hub/inter-hub-env`. The durable source file is:
|
||||
|
||||
```text
|
||||
deploy/railiance/secrets/inter-hub.env.sops.yaml
|
||||
```
|
||||
|
||||
Create or refresh it from the live Secret using:
|
||||
|
||||
```bash
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
|
||||
kubectl -n inter-hub get secret inter-hub-env -o json \
|
||||
| python3 deploy/railiance/secrets/k8s-secret-json-to-sops-input.py \
|
||||
> "$tmp"
|
||||
|
||||
sops --encrypt \
|
||||
--age age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 \
|
||||
"$tmp" > deploy/railiance/secrets/inter-hub.env.sops.yaml
|
||||
```
|
||||
|
||||
Apply the encrypted source:
|
||||
|
||||
```bash
|
||||
sops -d deploy/railiance/secrets/inter-hub.env.sops.yaml \
|
||||
| kubectl apply -f -
|
||||
kubectl rollout restart deployment/inter-hub -n inter-hub
|
||||
kubectl rollout status deployment/inter-hub -n inter-hub
|
||||
```
|
||||
|
||||
Custody-backed recovery verification:
|
||||
|
||||
```bash
|
||||
# after the approved custody unlock makes the age identity available
|
||||
make recovery-drill
|
||||
```
|
||||
|
||||
The drill prints UTC/local timestamps, verifies that the committed SOPS file can
|
||||
be decrypted in memory, checks the expected Secret metadata and key names, and
|
||||
does not print secret values. Keep the PASS output as non-secret recovery
|
||||
evidence.
|
||||
|
||||
## Database Migration
|
||||
|
||||
The current Nix production image is intentionally minimal: image metadata for
|
||||
`6455902` points at
|
||||
`/nix/store/<hash>-inter-hub/bin/RunProdServer`, and the package contains only
|
||||
`RunProdServer` and `RunJobs`. It has no shell and no packaged migration
|
||||
runner, so schema work is performed through the CloudNativePG pod.
|
||||
|
||||
Check schema state:
|
||||
```bash
|
||||
kubectl exec -n databases net-kingdom-pg-1 -- \
|
||||
psql -d interhub -Atc "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';"
|
||||
```
|
||||
|
||||
Initialize a blank production database from the canonical schema:
|
||||
```bash
|
||||
kubectl exec -i -n databases net-kingdom-pg-1 -- \
|
||||
psql -d interhub -v ON_ERROR_STOP=1 -1 -f - < Application/Schema.sql
|
||||
|
||||
kubectl exec -i -n databases net-kingdom-pg-1 -- \
|
||||
psql -d interhub -v ON_ERROR_STOP=1 -1 -f - < Application/Migration/1744502400-seed-type-registries.sql
|
||||
|
||||
kubectl exec -i -n databases net-kingdom-pg-1 -- psql -d interhub -v ON_ERROR_STOP=1 -1 -f - <<'SQL'
|
||||
GRANT USAGE ON SCHEMA public TO interhub;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO interhub;
|
||||
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO interhub;
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO interhub;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO interhub;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO interhub;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO interhub;
|
||||
SQL
|
||||
|
||||
kubectl rollout restart deployment/inter-hub -n inter-hub
|
||||
kubectl rollout status deployment/inter-hub -n inter-hub
|
||||
```
|
||||
|
||||
Do not apply `1744416000-seed-admin-user.sql` unattended in production; it uses
|
||||
a documented default password intended for initial local deployment only.
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
kubectl logs -n inter-hub -l app=inter-hub --tail=100 -f
|
||||
# Previous pod logs:
|
||||
kubectl logs -n inter-hub -l app=inter-hub --previous --tail=50
|
||||
```
|
||||
|
||||
## Restart / Rollback
|
||||
|
||||
```bash
|
||||
# Restart:
|
||||
kubectl rollout restart deployment/inter-hub -n inter-hub
|
||||
kubectl rollout status deployment/inter-hub -n inter-hub
|
||||
|
||||
# Rollback to previous image:
|
||||
kubectl rollout undo deployment/inter-hub -n inter-hub
|
||||
|
||||
# Rollback to specific version:
|
||||
helm rollback inter-hub 1 --namespace inter-hub
|
||||
```
|
||||
|
||||
## Secret Rotation
|
||||
|
||||
To rotate the session secret:
|
||||
```bash
|
||||
sops deploy/railiance/secrets/inter-hub.env.sops.yaml
|
||||
sops -d deploy/railiance/secrets/inter-hub.env.sops.yaml | kubectl apply -f -
|
||||
kubectl rollout restart deployment/inter-hub -n inter-hub
|
||||
```
|
||||
|
||||
To rotate the database password:
|
||||
1. Update the password in PostgreSQL (via kubectl exec to the CNPG pod)
|
||||
2. Update the `inter-hub-env` secret
|
||||
3. Restart the deployment
|
||||
|
||||
## Smoke Test
|
||||
|
||||
```bash
|
||||
getent ahosts hub.coulomb.social # expected: 92.205.130.254
|
||||
curl -fsS https://hub.coulomb.social/ | grep "inter-hub"
|
||||
curl -fsS https://hub.coulomb.social/api/v2/openapi.json >/dev/null
|
||||
curl -s -o /dev/null -w "%{http_code}" https://hub.coulomb.social/api/v2/widgets | grep 401
|
||||
curl -s -o /dev/null -w "%{http_code}" https://hub.coulomb.social/api/v2/hubs | grep 401
|
||||
```
|
||||
|
||||
## Database Connection Check
|
||||
|
||||
The IHP Nix image has no `/bin/sh`. Connect via the CNPG pod instead:
|
||||
```bash
|
||||
kubectl exec -n databases net-kingdom-pg-1 -- psql -U postgres -d interhub -c "SELECT version();"
|
||||
```
|
||||
|
||||
## Password Hashing
|
||||
|
||||
IHP uses `pwstore-fast` (`Crypto.PasswordStore`) — **not bcrypt**. Hash format:
|
||||
```
|
||||
sha256|17|<base64-salt>|<base64-hash>
|
||||
```
|
||||
|
||||
To generate a correct hash (requires GHC with pwstore-fast available on haskelseed):
|
||||
```bash
|
||||
ssh root@192.168.178.135
|
||||
cat > /tmp/genhash.hs << 'EOF'
|
||||
import qualified Crypto.PasswordStore as PS
|
||||
import qualified Data.ByteString.Char8 as B8
|
||||
main :: IO ()
|
||||
main = do
|
||||
h <- PS.makePassword (B8.pack "yourpassword") 17
|
||||
B8.putStrLn h
|
||||
EOF
|
||||
/nix/store/yp23474ys67f1fd2z2ff1nn3q5wrmjng-ghc-9.10.3-with-packages/bin/runghc /tmp/genhash.hs
|
||||
```
|
||||
|
||||
## haskelseed Build VM
|
||||
|
||||
- **Host:** 192.168.178.135
|
||||
- **Access:** ops-bridge SSH path with the approved operator key
|
||||
- **Role:** self-hosted Gitea Actions runner and Nix build machine only
|
||||
- **Runner:** OpenRC `act_runner` service registered to `https://gitea.coulomb.social`
|
||||
- **Build logs:** Gitea Actions logs and temporary runner work directories
|
||||
- **Nix store:** `/dev/sdb1` (100 GB, mounted at `/nix`)
|
||||
85
deploy/railiance/recovery-drill.sh
Executable file
85
deploy/railiance/recovery-drill.sh
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SECRET_FILE="${SECRET_FILE:-deploy/railiance/secrets/inter-hub.env.sops.yaml}"
|
||||
SOPS_BIN="${SOPS_BIN:-sops}"
|
||||
|
||||
timestamp_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
timestamp_local="$(date +"%Y-%m-%dT%H:%M:%S%z")"
|
||||
|
||||
echo "inter-hub recovery drill"
|
||||
echo "timestamp_utc=${timestamp_utc}"
|
||||
echo "timestamp_local=${timestamp_local}"
|
||||
echo "secret_file=${SECRET_FILE}"
|
||||
echo "sops_age_key_file=${SOPS_AGE_KEY_FILE:-<default>}"
|
||||
|
||||
if ! command -v "$SOPS_BIN" >/dev/null 2>&1; then
|
||||
echo "result=FAIL"
|
||||
echo "reason=sops-not-found"
|
||||
echo "hint=Install sops or run with SOPS_BIN=/path/to/sops."
|
||||
exit 127
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SECRET_FILE" ]]; then
|
||||
echo "result=FAIL"
|
||||
echo "reason=secret-file-not-found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||
echo "result=FAIL"
|
||||
echo "reason=python-yaml-not-found"
|
||||
echo "hint=Install PyYAML for python3 before running the recovery drill."
|
||||
exit 127
|
||||
fi
|
||||
|
||||
echo "sops_version=$("$SOPS_BIN" --version 2>/dev/null | sed -n '1p')"
|
||||
|
||||
if ! "$SOPS_BIN" filestatus "$SECRET_FILE" \
|
||||
| python3 -c 'import json, sys; data = json.load(sys.stdin); assert data.get("encrypted") is True'
|
||||
then
|
||||
echo "result=FAIL"
|
||||
echo "reason=sops-file-is-not-encrypted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
decrypt_err="$(mktemp)"
|
||||
trap 'rm -f "$decrypt_err"' EXIT
|
||||
|
||||
if ! decrypted_secret="$("$SOPS_BIN" --decrypt "$SECRET_FILE" 2>"$decrypt_err")"; then
|
||||
echo "result=FAIL"
|
||||
echo "reason=decrypt-failed"
|
||||
sed -n '1,6p' "$decrypt_err" | sed 's/^/sops_error=/'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! python3 -c '
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(sys.stdin)
|
||||
required = {"DATABASE_URL", "IHP_SESSION_SECRET", "IHP_BASEURL", "PORT", "IHP_ENV"}
|
||||
assert data["apiVersion"] == "v1"
|
||||
assert data["kind"] == "Secret"
|
||||
assert data["metadata"]["name"] == "inter-hub-env"
|
||||
assert data["metadata"]["namespace"] == "inter-hub"
|
||||
assert data["type"] == "Opaque"
|
||||
string_data = data["stringData"]
|
||||
missing = sorted(required - set(string_data))
|
||||
if missing:
|
||||
raise SystemExit(f"missing required keys: {missing}")
|
||||
for key in sorted(required):
|
||||
if not str(string_data[key]):
|
||||
raise SystemExit(f"empty required key: {key}")
|
||||
print("checked_metadata=inter-hub/inter-hub-env")
|
||||
print("checked_keys=" + ",".join(sorted(required)))
|
||||
' <<< "$decrypted_secret"
|
||||
then
|
||||
echo "result=FAIL"
|
||||
echo "reason=shape-check-failed"
|
||||
exit 1
|
||||
fi
|
||||
unset decrypted_secret
|
||||
|
||||
echo "result=PASS"
|
||||
echo "note=decrypted in memory only; secret values were not printed"
|
||||
6
deploy/railiance/secrets/.gitignore
vendored
Normal file
6
deploy/railiance/secrets/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
!*.example.yaml
|
||||
!*.sops.yaml
|
||||
!*.py
|
||||
76
deploy/railiance/secrets/README.md
Normal file
76
deploy/railiance/secrets/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# inter-hub Runtime Secret
|
||||
|
||||
`inter-hub.env.sops.yaml` is the durable source for the production
|
||||
`inter-hub/inter-hub-env` Kubernetes Secret. The file is encrypted with the
|
||||
shared Railiance age recipient declared in the repo root `.sops.yaml`.
|
||||
|
||||
Do not commit plaintext secret material. This directory ignores plaintext files
|
||||
by default; only `*.sops.yaml`, examples, docs, and helper scripts are tracked.
|
||||
|
||||
## Create Or Refresh
|
||||
|
||||
Use an attended operator shell with `kubectl`, `sops`, and access to the shared
|
||||
Railiance age identity:
|
||||
|
||||
```bash
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
|
||||
kubectl -n inter-hub get secret inter-hub-env -o json \
|
||||
| python3 deploy/railiance/secrets/k8s-secret-json-to-sops-input.py \
|
||||
> "$tmp"
|
||||
|
||||
sops --encrypt \
|
||||
--age age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 \
|
||||
"$tmp" > deploy/railiance/secrets/inter-hub.env.sops.yaml
|
||||
```
|
||||
|
||||
Review only non-secret metadata before committing:
|
||||
|
||||
```bash
|
||||
sops -d deploy/railiance/secrets/inter-hub.env.sops.yaml \
|
||||
| sed -n '1,8p'
|
||||
```
|
||||
|
||||
## Apply
|
||||
|
||||
```bash
|
||||
sops -d deploy/railiance/secrets/inter-hub.env.sops.yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
kubectl rollout restart deployment/inter-hub -n inter-hub
|
||||
kubectl rollout status deployment/inter-hub -n inter-hub
|
||||
```
|
||||
|
||||
## Recovery Drill
|
||||
|
||||
After the custody-backed age identity is unlocked, run:
|
||||
|
||||
```bash
|
||||
make recovery-drill
|
||||
```
|
||||
|
||||
If `sops` is not on `PATH`, pass it explicitly:
|
||||
|
||||
```bash
|
||||
SOPS_BIN=/path/to/sops make recovery-drill
|
||||
```
|
||||
|
||||
If the age identity is not in the default SOPS location, pass only the key-file
|
||||
path, not the key contents:
|
||||
|
||||
```bash
|
||||
SOPS_AGE_KEY_FILE=/path/to/custody-backed/age/keys.txt make recovery-drill
|
||||
```
|
||||
|
||||
The drill decrypts the committed SOPS file in memory, checks the expected
|
||||
Kubernetes Secret metadata and required key names, and prints timestamped
|
||||
PASS/FAIL evidence without printing secret values.
|
||||
|
||||
## Expected Keys
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `IHP_SESSION_SECRET`
|
||||
- `IHP_BASEURL`
|
||||
- `PORT`
|
||||
- `IHP_ENV`
|
||||
12
deploy/railiance/secrets/inter-hub.env.example.yaml
Normal file
12
deploy/railiance/secrets/inter-hub.env.example.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: inter-hub-env
|
||||
namespace: inter-hub
|
||||
type: Opaque
|
||||
stringData:
|
||||
DATABASE_URL: "postgresql://interhub:<password>@net-kingdom-pg-rw.databases.svc.cluster.local:5432/interhub?sslmode=disable"
|
||||
IHP_SESSION_SECRET: "<64-char-hex>"
|
||||
IHP_BASEURL: "https://hub.coulomb.social"
|
||||
PORT: "8000"
|
||||
IHP_ENV: "Production"
|
||||
27
deploy/railiance/secrets/inter-hub.env.sops.yaml
Normal file
27
deploy/railiance/secrets/inter-hub.env.sops.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: inter-hub-env
|
||||
namespace: inter-hub
|
||||
type: Opaque
|
||||
stringData:
|
||||
DATABASE_URL: ENC[AES256_GCM,data:uMryx592YJ4Puc1Dg3msJ251RGWW34zAsmc4oIhFZ5IrloOLOzKgBkzpYCnt0v6X5iQSWLayBbCI1clfFf6W5vLFvyWBNzfWzlc66sFiU/IG0qJZZIIWNnWUZmTqvN31gtSXjTYQM0lvDZBbSRjLwRRchMaG/LCrhUo+akhV3QMXWvpuDHnC82b0OaOwZRCnNM4=,iv:U9VdgQpZY+5OI5KaTTFvSejiibaH03RqTaBruKTgups=,tag:zWWVVB/zXvio6z8jzt8FYA==,type:str]
|
||||
IHP_BASEURL: ENC[AES256_GCM,data:GrIWPkoT3OroUgbZiLDsoBH6QgKbjROFkYU=,iv:Ky1ysaY6YQ0WRDywCG+WLys//8N4be2Lw8a0jJr7ovo=,tag:7+lyTiXfop+Q7CW66frWuw==,type:str]
|
||||
IHP_ENV: ENC[AES256_GCM,data:q4SFghcGM7Yodg==,iv:Vd1Dq+AKcxKayChG4PLeyTQvFpU7KEbGg/FpTqJzTps=,tag:yR+7AjKoWv/TrLvsQqRc8A==,type:str]
|
||||
IHP_SESSION_SECRET: ENC[AES256_GCM,data:vjhRzB6xXw6m5+9zUCMXAhJcBk7XZJCsA0GwqN+UvottYL/XEFKFPkeFco2YzxCnYZ5B1bdaFgK2eFVXs0qgrQ==,iv:JE9dEZvpldqreBufrvj6Keb7VFdXcJHhuZgMfeVsc1A=,tag:aWM9HGsoRD0z/LYLNoORJg==,type:str]
|
||||
PORT: ENC[AES256_GCM,data:4KBUgA==,iv:IPYTKvQVFlxy53OIJiyMnnM7LDN2qqdrn2VxWDbUaa8=,tag:J5a1jUcRi004FakTp7qEHA==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvNHZxdHFnMjRWOE5DMUhB
|
||||
NlgzSFUrT2FCUjR1cy8vdG9mcHRLcXcwT0VRCnl0cURjWUMyNTJSY1hYK3N4ZHRV
|
||||
bTJqQjR6SDNQOTJTb0ZmSGdWSXc5YVUKLS0tIDBvQUowR0ZLMDI5YUIvOEU2SkFS
|
||||
SlJ3TEJqeWx2MzlnanFWajFJaWQ0Sm8KglhHEIOrJrbWbQS0mUI2fGGmdkt9GUVr
|
||||
dBSr0HPa+DsNwStM2n6EJHADcF1+3CS2HP1JS0m58QkNfuJiF1EIZw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4
|
||||
encrypted_regex: ^(DATABASE_URL|IHP_SESSION_SECRET|IHP_BASEURL|PORT|IHP_ENV)$
|
||||
lastmodified: "2026-06-14T15:54:36Z"
|
||||
mac: ENC[AES256_GCM,data:Z5r73+ihZB1BUyFcC3E97G6/rQdcmDdujoUCNhbU8H2tLD3TlF8619nMt2KfOUiygiGdy+luBJYu9mgbc7zimR163E/JJjOLIRBErXQsYZOHYS2BL62xcNIGeII56UpJlnfVICFNtKYzmxmDI/ZFDMbZa1Z6q29SfUjY7WdnvjE=,iv:Frk1qAkfufNN0WHb9X0jyNureILOc/Ww0CbON2XArEs=,tag:vZ9ronrfa1Pt+f//MOsw2Q==,type:str]
|
||||
version: 3.13.1
|
||||
33
deploy/railiance/secrets/k8s-secret-json-to-sops-input.py
Executable file
33
deploy/railiance/secrets/k8s-secret-json-to-sops-input.py
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a Kubernetes Secret JSON document into a SOPS-ready Secret manifest.
|
||||
|
||||
The output contains decoded secret values under stringData and must be redirected
|
||||
to a temporary file, encrypted with sops, and removed immediately.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def yaml_string(value: str) -> str:
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
source = json.load(sys.stdin)
|
||||
metadata = source.get("metadata", {})
|
||||
name = metadata.get("name", "inter-hub-env")
|
||||
namespace = metadata.get("namespace", "inter-hub")
|
||||
data = source.get("data", {})
|
||||
|
||||
print("apiVersion: v1")
|
||||
print("kind: Secret")
|
||||
print("metadata:")
|
||||
print(f" name: {yaml_string(name)}")
|
||||
print(f" namespace: {yaml_string(namespace)}")
|
||||
print("type: Opaque")
|
||||
print("stringData:")
|
||||
|
||||
for key in sorted(data):
|
||||
decoded = base64.b64decode(data[key]).decode("utf-8")
|
||||
print(f" {key}: {yaml_string(decoded)}")
|
||||
139
docs/adhoc/2026-05-03-sidebar-nav-and-bugfixes.md
Normal file
139
docs/adhoc/2026-05-03-sidebar-nav-and-bugfixes.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
date: 2026-05-03
|
||||
author: tegwick (Claude Sonnet 4.6 pair)
|
||||
type: adhoc
|
||||
tags: [ui, navigation, bugfix, profiling]
|
||||
commits: 6078c48, 08d662d, e8b0c7c
|
||||
---
|
||||
|
||||
# Session: Registry Bugfixes + Left Sidebar Navigation
|
||||
|
||||
## What was done
|
||||
|
||||
### Bug fixes (commit 6078c48)
|
||||
|
||||
**Registry pages crashed on load** (`/WidgetTypeRegistry` and the three sibling tabs).
|
||||
Root cause: `IHP.NameSupport`'s Megaparsec-based runtime parser chokes on field names
|
||||
that end with a trailing underscore (e.g. `"label_"`). IHP generates `label_` as the
|
||||
Haskell record field name for the `label` SQL column to avoid shadowing the HTML `<label>`
|
||||
element. The parser accepts `label` but then fails when it encounters the trailing `_`.
|
||||
|
||||
Affected call sites:
|
||||
- `orderByAsc #label_` in `Web/Controller/TypeRegistries.hs` (4×) and
|
||||
`Web/Controller/Api/V2/Registries.hs` (3×) — crashes on page load
|
||||
- `textField #label_` in 4 view files — would crash on New/Edit form render
|
||||
|
||||
Fix: `orderByAsc #name` everywhere (semantically equivalent for these registries);
|
||||
manual `<input name="label_">` replacing `textField #label_` in forms.
|
||||
`fill @'["label_"]`, `validateField #label_`, and `createRecord`/`updateRecord`
|
||||
are NOT affected — they use explicit `columnNames` from generated types or HTTP
|
||||
param names, bypassing NameSupport.
|
||||
|
||||
**Logout returned 500** (`UnexpectedMethodException {allowedMethods = [DELETE], method = GET}`).
|
||||
The nav `<a href={DeleteSessionAction}>` issues a GET, but IHP routes `DeleteSessionAction`
|
||||
as DELETE. IHP ships `Network.Wai.Middleware.MethodOverridePost` in its middleware stack.
|
||||
Fix: replace the `<a>` with a POST form carrying `<input type="hidden" name="_method" value="DELETE">`.
|
||||
|
||||
**Seed migration had wrong password hash format.**
|
||||
`Application/Migration/1744416000-seed-admin-user.sql` used a bcrypt hash (`$2b$10$…`).
|
||||
IHP's `verifyPassword` uses `Crypto.PasswordStore.verifyPassword` from `pwstore-fast`,
|
||||
which produces and consumes PBKDF2-SHA256 hashes in the format `sha256|17|<b64salt>|<b64hash>`.
|
||||
The formats are incompatible. Fixed the migration; added a "Password Hashing" section to
|
||||
`deploy/railiance/RUNBOOK.md` with a runghc snippet for generating correct hashes on haskelseed.
|
||||
|
||||
### UI change: left sidebar navigation (commits 08d662d, e8b0c7c)
|
||||
|
||||
Moved all operational links out of a flat top nav (which was overflowing on any screen)
|
||||
into a grouped left sidebar (192 px wide). Top nav retains: logo (left) and
|
||||
About / Tutorial / Extend / Sign out (right).
|
||||
|
||||
Sidebar groups:
|
||||
| Group | Links |
|
||||
|---|---|
|
||||
| *(top, ungrouped)* | Hubs, Widgets |
|
||||
| Governance | Candidates, Requirements, Decisions, Deployments |
|
||||
| Intelligence | Agent Proposals, Agents, Routing, Collective, AI Gov, Learning |
|
||||
| Platform | Adapters, Propagations, Ops Review, Federation, Policies, Archive |
|
||||
| Registry | Registries, Extensions |
|
||||
| API & Market | API Consumers, API Dashboard, Hub Registry, Marketplace |
|
||||
|
||||
**Follow-up fix (e8b0c7c):** `flex-col` and `flex-1` were absent from the compiled
|
||||
`prod.css`. Tailwind's build only bundles classes that appear in templates at compile time;
|
||||
these were new to the codebase. Without `flex-col`, the body defaulted to `flex-row`,
|
||||
placing the top nav beside the sidebar instead of above it. Fixed by replacing
|
||||
layout-structural Tailwind classes with inline styles (`style="display:flex;flex-direction:column"`)
|
||||
so the column layout is independent of the CSS bundle. Visual Tailwind classes (colors,
|
||||
spacing, hover states) remain as-is.
|
||||
|
||||
---
|
||||
|
||||
## Profiling results
|
||||
|
||||
All times are wall-clock, measured between commit push and `kubectl rollout` completion.
|
||||
|
||||
| Phase | Bug-fix build | Sidebar build | Layout-fix build |
|
||||
|---|---|---|---|
|
||||
| Code edit | ~25 min (diagnosis + 12 edits) | ~8 min (1 file) | ~5 min (1 file, 4 lines) |
|
||||
| Commit + push to Gitea | <1 min | <1 min | <1 min |
|
||||
| `nix build .#docker` on haskelseed | ~37 min | ~46 min | ~10 min (`.hi` cache reuse) |
|
||||
| `skopeo` push to Gitea registry | ~1 min | ~1 min | ~1 min |
|
||||
| `kubectl set image` + rollout | ~2 min | ~2 min | ~2 min |
|
||||
| **Total** | **~66 min** | **~58 min** | **~55 min** |
|
||||
|
||||
The Nix build dominates in every case. A 4-line change costs the same as a 50-line change.
|
||||
|
||||
---
|
||||
|
||||
## Improvement suggestions
|
||||
|
||||
### 1. Dev-loop: GHCi hot-reload for template changes (highest impact)
|
||||
|
||||
The 46-minute build cycle is entirely driven by GHC recompiling the full app layer via
|
||||
Nix. In the `devenv` environment, `ghcid` reloads only changed modules in ~10–60 seconds.
|
||||
The current production pipeline skips `devenv` entirely.
|
||||
|
||||
**Proposal:** Use the existing `devenv` + `ghcid` setup for all iterative work (including
|
||||
layout changes), test locally, then trigger a single `nix build` only when a feature is
|
||||
ready to ship. This reduces the effective iteration cycle from 46 min to 1–2 min for
|
||||
UI/template work.
|
||||
|
||||
### 2. Nix binary cache for the app layer
|
||||
|
||||
Each `nix build` on haskelseed rebuilds `inter-hub-lib-0.1.0.drv` from scratch because
|
||||
Nix detects a new input hash whenever any source file changes. A Nix binary cache
|
||||
(e.g. `cachix`) would allow haskelseed to upload build outputs and reuse them across
|
||||
machines, but would not help with incremental rebuilds on the same machine since the
|
||||
derivation hash changes per-commit.
|
||||
|
||||
Partial improvement: configure Nix to use a remote builder with a warm store so that
|
||||
unchanged dependencies (IHP framework, GHC) are never fetched twice.
|
||||
|
||||
### 3. Split the Haskell package into stable-lib + app layers
|
||||
|
||||
Currently `inter-hub-lib` contains everything: IHP framework wiring AND all controllers
|
||||
and views. A single-file change causes the entire ~199-module package to recompile.
|
||||
|
||||
**Proposal:** Extract a stable `inter-hub-core` package (models, types, helpers) and
|
||||
keep a thin `inter-hub-app` package (controllers, views, FrontController). Nix would
|
||||
then only rebuild `inter-hub-app` for UI/controller changes, leaving `inter-hub-core`
|
||||
cached. Estimated saving: 20–30 min per UI-only change.
|
||||
|
||||
### 4. Tailwind CSS: safelist layout primitives
|
||||
|
||||
Layout-structural Tailwind classes (`flex-col`, `flex-1`, `min-h-screen`, `grid-cols-*`)
|
||||
are frequently needed but may be absent from the compiled CSS if they haven't been used
|
||||
before. Workaround (current): inline styles. Proper fix: add a `safelist` entry in
|
||||
`tailwind.config.js` for layout utilities, or maintain a CSS file with layout primitives
|
||||
that is always included regardless of template scanning.
|
||||
|
||||
### 5. IHP NameSupport — avoid trailing-underscore fields
|
||||
|
||||
IHP generates `label_` (trailing underscore) for columns named `label` to avoid shadowing
|
||||
the HSX `<label>` element. The NameSupport runtime parser cannot handle the trailing
|
||||
underscore in query positions (`orderByAsc`, `filterWhere`, `textField`). This is an IHP
|
||||
v1.5 bug. Two mitigations until it is fixed upstream:
|
||||
|
||||
- **Rename columns** at the schema level to avoid IHP-reserved words (`label` → `display_label`,
|
||||
`type` → `entry_type`, etc.). Requires a migration but permanently removes the fragility.
|
||||
- **Avoid `textField`/`orderByAsc` with underscore fields**; use raw inputs and order by a
|
||||
non-conflicting field (`#name`). This is the current workaround.
|
||||
220
docs/contracts/ops-hub-activity-core-event-payloads.md
Normal file
220
docs/contracts/ops-hub-activity-core-event-payloads.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Ops Hub Activity-Core Event Payloads
|
||||
|
||||
Date: 2026-06-15
|
||||
|
||||
Workplan: `IHUB-WP-0022`
|
||||
|
||||
## Inter-Hub Request Shape
|
||||
|
||||
Activity-core should submit ops evidence through:
|
||||
|
||||
```text
|
||||
POST /api/v2/interaction-events
|
||||
Authorization: Bearer ${OPS_HUB_KEY}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Each request body must use the Inter-Hub v2 interaction-event shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"widgetId": "<widget-uuid-from-OPS_HUB_WIDGET_MAPPING>",
|
||||
"eventType": "ops-endpoint-verified",
|
||||
"viewContext": "ops-inventory-probe",
|
||||
"metadata": {
|
||||
"type": "ops-endpoint-verified",
|
||||
"version": "1.0",
|
||||
"publisher": "activity-core",
|
||||
"attributes": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inter-Hub sets `occurredAt` on receipt. Activity-core must send the actual
|
||||
probe timestamp as `metadata.attributes.observed_at`.
|
||||
|
||||
## Shared Rules
|
||||
|
||||
- `widgetId` must be a UUID for an existing ops-hub widget.
|
||||
- `eventType` must exist in Inter-Hub's event type registry.
|
||||
- If the API consumer is bound to an active ops-hub manifest, `eventType` must
|
||||
be declared by that manifest.
|
||||
- `viewContext` should be `ops-inventory-probe` unless a more specific context
|
||||
is useful, such as `ops-inventory-probe/endpoints`.
|
||||
- `metadata.type` must match the Inter-Hub `eventType`.
|
||||
- `metadata.version` must match the activity-core event definition version.
|
||||
- `metadata.publisher` must be `activity-core`.
|
||||
- `metadata.attributes.idempotency_key` is required, even though Inter-Hub does
|
||||
not currently enforce idempotency.
|
||||
- Duplicate tolerance is required on the reader side until Inter-Hub provides a
|
||||
unique idempotency constraint.
|
||||
- Payloads must not include secrets, authorization headers, cookies, token-like
|
||||
values, private key material, raw response bodies, command output, or
|
||||
unredacted URL query strings.
|
||||
|
||||
## Status Vocabulary
|
||||
|
||||
Use the activity-core status vocabulary:
|
||||
|
||||
- `ok`
|
||||
- `degraded`
|
||||
- `down`
|
||||
- `skipped`
|
||||
|
||||
Use `reason` for compact machine-readable explanations, for example:
|
||||
|
||||
- `expected_status_mismatch`
|
||||
- `expected_signal_missing`
|
||||
- `unsupported_access_path_type`
|
||||
- `backup_probe_not_implemented`
|
||||
- `missing_endpoint`
|
||||
|
||||
## Example: Service Observed
|
||||
|
||||
```json
|
||||
{
|
||||
"widgetId": "<service-widget-uuid>",
|
||||
"eventType": "ops-service-observed",
|
||||
"viewContext": "ops-inventory-probe/services",
|
||||
"metadata": {
|
||||
"type": "ops-service-observed",
|
||||
"version": "1.0",
|
||||
"publisher": "activity-core",
|
||||
"attributes": {
|
||||
"activity_core_run_id": "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
"idempotency_key": "12345678-aaaa-bbbb-cccc-123456789abc:state-hub:ops-service-observed",
|
||||
"service_id": "state-hub",
|
||||
"service_name": "State Hub",
|
||||
"environment": "local",
|
||||
"lifecycle_state": "observed",
|
||||
"observed_status": "ok",
|
||||
"observed_at": "2026-06-05T10:15:01Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Endpoint Verified
|
||||
|
||||
```json
|
||||
{
|
||||
"widgetId": "<endpoint-widget-uuid>",
|
||||
"eventType": "ops-endpoint-verified",
|
||||
"viewContext": "ops-inventory-probe/endpoints",
|
||||
"metadata": {
|
||||
"type": "ops-endpoint-verified",
|
||||
"version": "1.0",
|
||||
"publisher": "activity-core",
|
||||
"attributes": {
|
||||
"activity_core_run_id": "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
"idempotency_key": "12345678-aaaa-bbbb-cccc-123456789abc:gitea:gitea-oci-registry:ops-endpoint-verified",
|
||||
"service_id": "gitea",
|
||||
"endpoint_id": "gitea-oci-registry",
|
||||
"endpoint_type": "https",
|
||||
"endpoint_url": "https://gitea.coulomb.social/v2/",
|
||||
"expected_status": 401,
|
||||
"status_code": 401,
|
||||
"matched_expected_status": true,
|
||||
"matched_expected_signal": true,
|
||||
"observed_status": "ok",
|
||||
"observed_at": "2026-06-05T10:15:01Z",
|
||||
"widget_ref": "ops:endpoint:gitea-registry"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Access Path Checked
|
||||
|
||||
```json
|
||||
{
|
||||
"widgetId": "<access-path-widget-uuid>",
|
||||
"eventType": "ops-access-path-checked",
|
||||
"viewContext": "ops-inventory-probe/access-paths",
|
||||
"metadata": {
|
||||
"type": "ops-access-path-checked",
|
||||
"version": "1.0",
|
||||
"publisher": "activity-core",
|
||||
"attributes": {
|
||||
"activity_core_run_id": "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
"idempotency_key": "12345678-aaaa-bbbb-cccc-123456789abc:gitea:gitea-access-1:ops-access-path-checked",
|
||||
"service_id": "gitea",
|
||||
"access_path_id": "gitea-access-1",
|
||||
"access_path_type": "k8s",
|
||||
"declared_status": "unknown",
|
||||
"observed_status": "skipped",
|
||||
"reason": "unsupported_access_path_type",
|
||||
"observed_at": "2026-06-05T10:15:01Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Backup Verified
|
||||
|
||||
```json
|
||||
{
|
||||
"widgetId": "<backup-widget-uuid>",
|
||||
"eventType": "ops-backup-verified",
|
||||
"viewContext": "ops-inventory-probe/backups",
|
||||
"metadata": {
|
||||
"type": "ops-backup-verified",
|
||||
"version": "1.0",
|
||||
"publisher": "activity-core",
|
||||
"attributes": {
|
||||
"activity_core_run_id": "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
"idempotency_key": "12345678-aaaa-bbbb-cccc-123456789abc:gitea:database:gitea-db:ops-backup-verified",
|
||||
"service_id": "gitea",
|
||||
"backing_store_ref": "database:gitea-db",
|
||||
"backup_evidence_ref": "state-hub:progress:<progress-id>",
|
||||
"restore_verified": false,
|
||||
"observed_status": "skipped",
|
||||
"reason": "backup_probe_not_implemented",
|
||||
"observed_at": "2026-06-05T10:15:01Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Inventory Drift
|
||||
|
||||
```json
|
||||
{
|
||||
"widgetId": "<drift-widget-uuid>",
|
||||
"eventType": "ops-inventory-drift",
|
||||
"viewContext": "ops-inventory-probe/drift",
|
||||
"metadata": {
|
||||
"type": "ops-inventory-drift",
|
||||
"version": "1.0",
|
||||
"publisher": "activity-core",
|
||||
"attributes": {
|
||||
"activity_core_run_id": "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
"idempotency_key": "12345678-aaaa-bbbb-cccc-123456789abc:gitea:gitea-oci-registry:ops-inventory-drift",
|
||||
"service_id": "gitea",
|
||||
"inventory_object_id": "gitea-oci-registry",
|
||||
"drift_kind": "status_mismatch",
|
||||
"declared_summary": "expected_status=401",
|
||||
"observed_summary": "status_code=200",
|
||||
"observed_status": "degraded",
|
||||
"reason": "expected_status_mismatch",
|
||||
"observed_at": "2026-06-05T10:15:01Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Expected API Errors
|
||||
|
||||
Activity-core should treat these as configuration or rollout errors:
|
||||
|
||||
| Error | Meaning | Recovery |
|
||||
|---|---|---|
|
||||
| `401` | Missing or invalid `OPS_HUB_KEY` | Check Secret provisioning; do not log the key. |
|
||||
| `422` with `unregistered_event_type` | Event type not in Inter-Hub registry | Activate the ops-hub manifest vocabulary. |
|
||||
| `422` with `event_type_not_in_manifest` | Runtime consumer manifest does not declare the event | Bind the consumer to the active manifest or activate a corrected manifest. |
|
||||
| `422` with `Widget not found` | Mapping points at a missing widget | Refresh `OPS_HUB_WIDGET_MAPPING`. |
|
||||
| `422` with `unregistered_policy_scope` during widget seed | Policy scope is absent | Declare and activate `ops-evidence`. |
|
||||
|
||||
For the first activity-core slice, a failed Inter-Hub submission should not
|
||||
fail the whole probe if State Hub fallback posting succeeds. It should return a
|
||||
compact sink result naming the non-secret failure class.
|
||||
206
docs/contracts/ops-hub-activity-core-mapping.md
Normal file
206
docs/contracts/ops-hub-activity-core-mapping.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Ops Hub Activity-Core Widget Mapping
|
||||
|
||||
Date: 2026-06-15
|
||||
|
||||
Workplan: `IHUB-WP-0022`
|
||||
|
||||
## Purpose
|
||||
|
||||
`OPS_HUB_WIDGET_MAPPING` tells activity-core which Inter-Hub widget receives
|
||||
each ops evidence event. The value must be non-secret JSON. It may contain
|
||||
Inter-Hub widget UUIDs and logical references, but it must never contain
|
||||
`OPS_HUB_KEY` or any operator credential.
|
||||
|
||||
Activity-core currently only checks that a mapping value is present before
|
||||
returning `inter_hub_sink_deferred`. This document defines the contract that
|
||||
the future Inter-Hub submission implementation should parse.
|
||||
|
||||
## Versioned Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "ops-hub.activity-core.widget-mapping.v1",
|
||||
"hub": {
|
||||
"slug": "ops-hub",
|
||||
"id": "<ops-hub-uuid>"
|
||||
},
|
||||
"policyScope": "ops-evidence",
|
||||
"defaultViewContext": "ops-inventory-probe",
|
||||
"events": {
|
||||
"ops-service-observed": {
|
||||
"family": "services",
|
||||
"aggregateWidgetRef": "ops:service:aggregate"
|
||||
},
|
||||
"ops-endpoint-verified": {
|
||||
"family": "endpoints",
|
||||
"aggregateWidgetRef": "ops:endpoint:aggregate"
|
||||
},
|
||||
"ops-access-path-checked": {
|
||||
"family": "accessPaths",
|
||||
"aggregateWidgetRef": "ops:access-path:aggregate"
|
||||
},
|
||||
"ops-backup-verified": {
|
||||
"family": "backups",
|
||||
"aggregateWidgetRef": "ops:backup:aggregate"
|
||||
},
|
||||
"ops-inventory-drift": {
|
||||
"family": "drift",
|
||||
"aggregateWidgetRef": "ops:drift:aggregate"
|
||||
}
|
||||
},
|
||||
"widgets": {
|
||||
"aggregate": {
|
||||
"ops:service:aggregate": {
|
||||
"widgetId": "<uuid>",
|
||||
"widgetType": "ops-service-card",
|
||||
"name": "Ops Service Evidence Intake"
|
||||
},
|
||||
"ops:endpoint:aggregate": {
|
||||
"widgetId": "<uuid>",
|
||||
"widgetType": "ops-endpoint-card",
|
||||
"name": "Ops Endpoint Evidence Intake"
|
||||
},
|
||||
"ops:access-path:aggregate": {
|
||||
"widgetId": "<uuid>",
|
||||
"widgetType": "ops-access-path-card",
|
||||
"name": "Ops Access Path Evidence Intake"
|
||||
},
|
||||
"ops:backup:aggregate": {
|
||||
"widgetId": "<uuid>",
|
||||
"widgetType": "ops-backup-card",
|
||||
"name": "Ops Backup Evidence Intake"
|
||||
},
|
||||
"ops:drift:aggregate": {
|
||||
"widgetId": "<uuid>",
|
||||
"widgetType": "ops-drift-card",
|
||||
"name": "Ops Inventory Drift Evidence Intake"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"state-hub": {
|
||||
"widgetRef": "ops:service:state-hub",
|
||||
"widgetId": "<uuid>"
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
"gitea:gitea-oci-registry": {
|
||||
"widgetRef": "ops:endpoint:gitea-registry",
|
||||
"widgetId": "<uuid>"
|
||||
}
|
||||
},
|
||||
"accessPaths": {
|
||||
"gitea:gitea-access-1": {
|
||||
"widgetRef": "ops:access-path:gitea-access-1",
|
||||
"widgetId": "<uuid>"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
"gitea:database:gitea-db": {
|
||||
"widgetRef": "ops:backup:gitea-db",
|
||||
"widgetId": "<uuid>"
|
||||
}
|
||||
},
|
||||
"drift": {
|
||||
"gitea:gitea-oci-registry": {
|
||||
"widgetRef": "ops:drift:gitea-oci-registry",
|
||||
"widgetId": "<uuid>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Selector Rules
|
||||
|
||||
Activity-core should choose a widget in this order:
|
||||
|
||||
1. If the evidence payload carries a `widget_ref` and that reference exists in
|
||||
the mapping, use it.
|
||||
2. For `ops-service-observed`, use `services["<service_id>"]`.
|
||||
3. For `ops-endpoint-verified`, use
|
||||
`endpoints["<service_id>:<endpoint_id>"]`.
|
||||
4. For `ops-access-path-checked`, use
|
||||
`accessPaths["<service_id>:<access_path_id>"]`.
|
||||
5. For `ops-backup-verified`, use
|
||||
`backups["<service_id>:<backing_store_ref>"]`.
|
||||
6. For `ops-inventory-drift`, use
|
||||
`drift["<service_id>:<inventory_object_id>"]`.
|
||||
7. If no entity-specific widget exists, use the event's aggregate widget.
|
||||
8. If neither an entity-specific nor aggregate widget exists, skip Inter-Hub
|
||||
submission with a non-secret result that names the missing selector.
|
||||
|
||||
## Bootstrap Widget Names
|
||||
|
||||
The initial aggregate widgets should be seeded before activity-core is pointed
|
||||
at Inter-Hub:
|
||||
|
||||
| Widget ref | Widget type | Suggested name |
|
||||
|---|---|---|
|
||||
| `ops:service:aggregate` | `ops-service-card` | Ops Service Evidence Intake |
|
||||
| `ops:endpoint:aggregate` | `ops-endpoint-card` | Ops Endpoint Evidence Intake |
|
||||
| `ops:access-path:aggregate` | `ops-access-path-card` | Ops Access Path Evidence Intake |
|
||||
| `ops:backup:aggregate` | `ops-backup-card` | Ops Backup Evidence Intake |
|
||||
| `ops:drift:aggregate` | `ops-drift-card` | Ops Inventory Drift Evidence Intake |
|
||||
|
||||
Per-entity widgets may be seeded later without changing the event contract.
|
||||
When a per-entity widget is added, update the mapping and keep the aggregate
|
||||
widget as the fallback.
|
||||
|
||||
## Compatibility Rules
|
||||
|
||||
- `version` is required. Reject unknown major versions.
|
||||
- Consumers must tolerate additional fields.
|
||||
- Widget UUIDs may rotate, but `widgetRef` values should remain stable.
|
||||
- Removing a widget mapping is a breaking change for that selector unless the
|
||||
aggregate fallback remains present.
|
||||
- Mapping updates must be deployed before activity-core starts sending events
|
||||
that depend on the new selectors.
|
||||
- The mapping is non-secret and may be stored in a ConfigMap or environment
|
||||
variable. `OPS_HUB_KEY` must remain Secret-only.
|
||||
|
||||
## Minimum Valid Mapping
|
||||
|
||||
For the first live smoke, an aggregate-only mapping is enough:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "ops-hub.activity-core.widget-mapping.v1",
|
||||
"hub": {
|
||||
"slug": "ops-hub",
|
||||
"id": "<ops-hub-uuid>"
|
||||
},
|
||||
"policyScope": "ops-evidence",
|
||||
"defaultViewContext": "ops-inventory-probe",
|
||||
"events": {
|
||||
"ops-service-observed": {
|
||||
"family": "services",
|
||||
"aggregateWidgetRef": "ops:service:aggregate"
|
||||
},
|
||||
"ops-endpoint-verified": {
|
||||
"family": "endpoints",
|
||||
"aggregateWidgetRef": "ops:endpoint:aggregate"
|
||||
},
|
||||
"ops-access-path-checked": {
|
||||
"family": "accessPaths",
|
||||
"aggregateWidgetRef": "ops:access-path:aggregate"
|
||||
},
|
||||
"ops-backup-verified": {
|
||||
"family": "backups",
|
||||
"aggregateWidgetRef": "ops:backup:aggregate"
|
||||
},
|
||||
"ops-inventory-drift": {
|
||||
"family": "drift",
|
||||
"aggregateWidgetRef": "ops:drift:aggregate"
|
||||
}
|
||||
},
|
||||
"widgets": {
|
||||
"aggregate": {
|
||||
"ops:service:aggregate": { "widgetId": "<uuid>" },
|
||||
"ops:endpoint:aggregate": { "widgetId": "<uuid>" },
|
||||
"ops:access-path:aggregate": { "widgetId": "<uuid>" },
|
||||
"ops:backup:aggregate": { "widgetId": "<uuid>" },
|
||||
"ops:drift:aggregate": { "widgetId": "<uuid>" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
95
docs/evidence/ops-hub-activity-core-fallback-validation.md
Normal file
95
docs/evidence/ops-hub-activity-core-fallback-validation.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Ops Hub Activity-Core Fallback Validation
|
||||
|
||||
Date: 2026-06-15
|
||||
|
||||
Workplan: `IHUB-WP-0022`
|
||||
|
||||
## Validation Result
|
||||
|
||||
The State Hub fallback path is implemented and tested in activity-core, but no
|
||||
live fallback event exists in State Hub yet.
|
||||
|
||||
Direct query:
|
||||
|
||||
```text
|
||||
GET http://127.0.0.1:8000/progress/?event_type=ops_inventory_probe&limit=20
|
||||
```
|
||||
|
||||
Observed result on 2026-06-15:
|
||||
|
||||
```json
|
||||
[]
|
||||
```
|
||||
|
||||
This means Inter-Hub can accept the fallback-first design, but cannot yet cite
|
||||
live `ops_inventory_probe` evidence as a closure artifact.
|
||||
|
||||
## What Is Validated
|
||||
|
||||
Activity-core local tests validate the fallback sink shape:
|
||||
|
||||
- `state-hub-progress` posts one compact `ops_inventory_probe` progress event
|
||||
per run.
|
||||
- The fallback idempotency key is stable:
|
||||
`activity_core_run_id:context_key:ops_inventory_probe`.
|
||||
- The posted summary is compact, for example:
|
||||
`Ops inventory probe: 1 ok, 0 degraded, 0 down, 1 skipped`.
|
||||
- The detail includes `activity_id`, `activity_core_run_id`, `scheduled_for`,
|
||||
`source_type`, `context_key`, `idempotency_key`, and a compact `probe`
|
||||
payload.
|
||||
- The compact probe strips response bodies, authorization headers, credentials,
|
||||
URL query strings, and token-like values.
|
||||
- Inter-Hub sink config absence is a clean skip with
|
||||
`reason = missing_inter_hub_config`.
|
||||
- When config is present but submission is not implemented, the result is a
|
||||
clean skip with `reason = inter_hub_sink_deferred`.
|
||||
|
||||
## What Is Not Yet Validated
|
||||
|
||||
- No live activity-core worker has posted an `ops_inventory_probe` event to the
|
||||
local State Hub instance.
|
||||
- No live Inter-Hub event has been submitted from activity-core.
|
||||
- No production `OPS_HUB_KEY` handoff has been verified.
|
||||
- No `OPS_HUB_WIDGET_MAPPING` has been deployed into the activity-core runtime.
|
||||
- No per-entity widget mapping has been smoke-tested against production
|
||||
ops-hub widgets.
|
||||
|
||||
## Gaps Compared With Inter-Hub Submission
|
||||
|
||||
State Hub fallback is useful as continuity evidence, but it is not a full
|
||||
replacement for Inter-Hub submission:
|
||||
|
||||
- It records one compact run summary, not one governed widget event per entity.
|
||||
- It cannot attach annotations directly to the affected service or endpoint
|
||||
widget.
|
||||
- It does not prove ops-hub manifest vocabulary enforcement.
|
||||
- It does not prove API consumer, key, rate-limit, or widget mapping behavior.
|
||||
- It does not populate Inter-Hub event lists for hub dashboards or downstream
|
||||
widget governance workflows.
|
||||
|
||||
## Closure Recommendation
|
||||
|
||||
`ACTIVITY-WP-0007/T06` should not close as fully activated based on the current
|
||||
State Hub state, because no live `ops_inventory_probe` progress event exists.
|
||||
|
||||
Two acceptable closure paths remain:
|
||||
|
||||
1. Fallback-deferred closure: run one disabled/manual activity-core probe,
|
||||
confirm a live non-secret `ops_inventory_probe` progress event in State Hub,
|
||||
and explicitly record that Inter-Hub submission is deferred until
|
||||
`IHUB-WP-0022-T03/T04/T07` complete.
|
||||
2. Full Inter-Hub closure: provision `OPS_HUB_KEY`, deploy
|
||||
`OPS_HUB_WIDGET_MAPPING`, seed/verify the ops-hub widgets, and submit one
|
||||
accepted Inter-Hub event per activity-core event type.
|
||||
|
||||
Until one of those paths is satisfied, Inter-Hub should keep the activity-core
|
||||
closure gate open.
|
||||
|
||||
## Next Evidence To Capture
|
||||
|
||||
- Progress id for the first live `ops_inventory_probe` event.
|
||||
- Non-secret summary counts from that progress detail.
|
||||
- Confirmation that Inter-Hub sink remains skipped cleanly while config is
|
||||
absent or deferred.
|
||||
- After ops-hub activation, event ids for one accepted Inter-Hub submission per
|
||||
event type.
|
||||
347
docs/new-hub-quickstart.md
Normal file
347
docs/new-hub-quickstart.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# New Domain Hub — Quickstart Guide
|
||||
|
||||
**Audience:** A developer starting a new domain hub (dev-hub, ops-hub, fin-hub, etc.)
|
||||
that will live in its own repository and use inter-hub as the governance substrate.
|
||||
|
||||
**Current state:** inter-hub v0.2.0-alpha.1 exposes its supported integration
|
||||
surface under `/api/v2`. The examples below use `$IHUB_BASE`; point it at the
|
||||
environment you are bootstrapping against.
|
||||
|
||||
---
|
||||
|
||||
## Two Patterns — Choose One
|
||||
|
||||
### Pattern A: API Consumer Hub (any language, start today)
|
||||
|
||||
Your hub is a standalone application that talks to inter-hub via REST API.
|
||||
No Haskell required. Full framework services available from day one.
|
||||
|
||||
**Best for:** Hubs that already have a tech stack (Node, Python, Go, etc.),
|
||||
prototypes, or teams that want zero build overhead.
|
||||
|
||||
### Pattern B: IHP Extension Hub (Haskell, shares build infra)
|
||||
|
||||
Your hub is a separate IHP project that runs alongside inter-hub, sharing
|
||||
the same Nix/GHC installation on haskelseed and optionally the same
|
||||
PostgreSQL cluster (different schema or database).
|
||||
|
||||
**Best for:** Hubs that need server-rendered UI, deep governance integration,
|
||||
or type-safe access to inter-hub's data model.
|
||||
|
||||
---
|
||||
|
||||
## Pattern A — API Consumer Hub
|
||||
|
||||
### 1. Start with an operator API key
|
||||
|
||||
Every write call below requires `Authorization: Bearer <key>`. Use an existing
|
||||
operator/admin API key for the first bootstrap call. New hub-specific keys can
|
||||
then be created through the API and should replace the operator key for normal
|
||||
runtime traffic.
|
||||
|
||||
```bash
|
||||
export IHUB_BASE="http://127.0.0.1:8000"
|
||||
export IHUB_OPERATOR_KEY="<existing-operator-api-key>"
|
||||
```
|
||||
|
||||
### 2. Register the VSM Operations hub
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$IHUB_BASE/api/v2/hubs" \
|
||||
-H "Authorization: Bearer $IHUB_OPERATOR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Operations Hub",
|
||||
"slug": "ops-hub",
|
||||
"domain": "operations",
|
||||
"hubKind": "domain",
|
||||
"hubFamily": "vsm",
|
||||
"vsmFunction": "operations",
|
||||
"vsmSystem": "1"
|
||||
}'
|
||||
```
|
||||
|
||||
Save the returned `id` — this is your `hubId` for all subsequent calls.
|
||||
|
||||
### 3. Register and activate the ops-hub manifest
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$IHUB_BASE/api/v2/hub-capability-manifests" \
|
||||
-H "Authorization: Bearer $IHUB_OPERATOR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"hubId": "<ops-hub-id>",
|
||||
"manifestVersion": "1.0",
|
||||
"declaredWidgetTypes": ["ops-endpoint-card"],
|
||||
"declaredEventTypes": ["ops-endpoint-verified"],
|
||||
"declaredAnnotationCategories": ["ops-risk"],
|
||||
"declaredPolicyScopes": ["ops-internal"],
|
||||
"capabilityDescription": "Operations inventory and endpoint verification",
|
||||
"contact": "ops@example.com"
|
||||
}'
|
||||
```
|
||||
|
||||
Then activate the returned manifest:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$IHUB_BASE/api/v2/hub-capability-manifests/<manifest-id>/activate" \
|
||||
-H "Authorization: Bearer $IHUB_OPERATOR_KEY"
|
||||
```
|
||||
|
||||
Activation registers the declared vocabulary. Domain-owned widget types,
|
||||
event types, annotation categories, and policy scopes must be declared here
|
||||
before use.
|
||||
|
||||
### 4. Create an ops-hub API consumer and key
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$IHUB_BASE/api/v2/api-consumers" \
|
||||
-H "Authorization: Bearer $IHUB_OPERATOR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "ops-hub",
|
||||
"description": "Operations hub runtime client",
|
||||
"hubCapabilityManifestId": "<active-manifest-id>",
|
||||
"rateLimitPerMinute": 120,
|
||||
"quotaPerDay": 50000
|
||||
}'
|
||||
```
|
||||
|
||||
Create the static key for the returned consumer:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$IHUB_BASE/api/v2/api-consumers/<api-consumer-id>/api-keys" \
|
||||
-H "Authorization: Bearer $IHUB_OPERATOR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"scopes": "ops:write"}'
|
||||
```
|
||||
|
||||
The response contains `fullKey` exactly once. Store it in the hub runtime
|
||||
secret store and use it for all following calls:
|
||||
|
||||
```bash
|
||||
export OPS_HUB_KEY="<fullKey-from-create-api-key-response>"
|
||||
```
|
||||
|
||||
### 5. Register widgets
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$IHUB_BASE/api/v2/widgets" \
|
||||
-H "Authorization: Bearer $OPS_HUB_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "CoulombCore Gitea Registry",
|
||||
"widgetType": "ops-endpoint-card",
|
||||
"hubId": "<ops-hub-id>",
|
||||
"viewContext": "operations-inventory",
|
||||
"policyScope": "ops-internal"
|
||||
}'
|
||||
```
|
||||
|
||||
### 6. Record interaction events
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$IHUB_BASE/api/v2/interaction-events" \
|
||||
-H "Authorization: Bearer $OPS_HUB_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"widgetId": "<widget-id>",
|
||||
"eventType": "ops-endpoint-verified",
|
||||
"viewContext": "registry-readiness",
|
||||
"metadata": {
|
||||
"service": "gitea",
|
||||
"endpoint": "https://gitea.coulomb.social/v2/",
|
||||
"result": "auth-challenge-ok"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 7. Verify the bootstrap
|
||||
|
||||
```bash
|
||||
curl -s "$IHUB_BASE/api/v2/interaction-events?widgetId=<widget-id>&eventType=ops-endpoint-verified" \
|
||||
-H "Authorization: Bearer $OPS_HUB_KEY"
|
||||
```
|
||||
|
||||
The event should appear with the submitted `metadata`. If the API returns
|
||||
`event_type_not_in_manifest`, check that the API consumer is bound to the
|
||||
active ops-hub manifest and that the event type was declared before activation.
|
||||
|
||||
The same path is available as a smoke script:
|
||||
|
||||
```bash
|
||||
IHUB_BASE="$IHUB_BASE" IHUB_OPERATOR_KEY="$IHUB_OPERATOR_KEY" \
|
||||
scripts/ops-hub-bootstrap-smoke.py
|
||||
```
|
||||
|
||||
### 8. What you get for free
|
||||
|
||||
Once events are flowing, the inter-hub framework automatically provides:
|
||||
- Annotation collection on any widget
|
||||
- Requirement candidate escalation from annotations
|
||||
- Triage queue and governance lifecycle (Requirement → Decision → Deployment)
|
||||
- AI-assisted requirement drafting (if AgentRegistration is configured)
|
||||
- Outcome signals and regression detection
|
||||
- Widget marketplace discovery
|
||||
|
||||
Your hub only needs to register its vocabulary, seed meaningful widgets, and
|
||||
POST events. Everything downstream is managed by inter-hub.
|
||||
|
||||
---
|
||||
|
||||
## Pattern B — IHP Extension Hub (Haskell)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The same build infrastructure used for inter-hub works directly:
|
||||
- haskelseed VM (`192.168.178.135`) as the CI/Nix build runner with GHC 9.10.3
|
||||
in the Nix store
|
||||
- `devenv` for reproducible environments
|
||||
- The painful one-time Nix setup is already done — a new IHP project reuses
|
||||
the same Nix store when built on the runner
|
||||
|
||||
### Bootstrap a new hub repo
|
||||
|
||||
```bash
|
||||
# On your workstation (Nix must be installed)
|
||||
nix profile install nixpkgs#ihp-new
|
||||
ihp-new dev-hub
|
||||
cd dev-hub
|
||||
|
||||
# Edit devenv.nix to pin to the same IHP version as inter-hub (1.5.0)
|
||||
# Then:
|
||||
devenv up
|
||||
```
|
||||
|
||||
The first `devenv up` on a fresh machine takes 20–40 min to fetch Nix
|
||||
dependencies. On haskelseed, most dependencies are already in the Nix store,
|
||||
which is why it is useful as a build runner. It is not the production runtime
|
||||
host for inter-hub.
|
||||
|
||||
### Connect to inter-hub's API
|
||||
|
||||
Add the inter-hub API client to your hub. The simplest approach:
|
||||
|
||||
```haskell
|
||||
-- Application/Helper/InterHubClient.hs
|
||||
module Application.Helper.InterHubClient where
|
||||
|
||||
import IHP.Prelude
|
||||
import Network.HTTP.Simple
|
||||
|
||||
postEvent :: Text -> Text -> Text -> Value -> IO ()
|
||||
postEvent apiKey widgetId eventType metadata = do
|
||||
let req = setRequestMethod "POST"
|
||||
$ setRequestHeader "Authorization" ["Bearer " <> cs apiKey]
|
||||
$ setRequestHeader "Content-Type" ["application/json"]
|
||||
$ setRequestBodyJSON (object
|
||||
[ "widgetId" .= widgetId
|
||||
, "eventType" .= eventType
|
||||
, "metadata" .= metadata
|
||||
])
|
||||
$ parseRequest_ "http://127.0.0.1:8000/api/v2/interaction-events"
|
||||
void $ httpLBS req
|
||||
```
|
||||
|
||||
### Shared database (optional)
|
||||
|
||||
Production inter-hub runs on Railiance01 K3s and uses PostgreSQL inside the
|
||||
Railiance cluster. Do not connect new hubs to a haskelseed database. Prefer the
|
||||
API boundary for extension hubs; request a governed read model or dedicated
|
||||
service account if a hub truly needs database-level integration.
|
||||
|
||||
### How fast is the Haskell build for a new hub?
|
||||
|
||||
A fresh IHP project with 10 controllers and 20 views compiles to ~150
|
||||
modules (vs inter-hub's 616). With the Nix store already populated on
|
||||
haskelseed:
|
||||
|
||||
| Stage | Time |
|
||||
|-------|------|
|
||||
| First `devenv up` (Nix fetch) | ~2 min (store populated) |
|
||||
| First GHCi load (150 modules) | ~3–5 min |
|
||||
| Incremental reload (1 module changed) | ~5–15 s |
|
||||
| Adding a new controller+view pair | ~10–30 s compile time |
|
||||
|
||||
This is practical for active development. The painful build experience with
|
||||
inter-hub was caused by its scale (616 modules, 12 phases worth of code)
|
||||
and the Alpine setup being done from scratch. A new hub starts small.
|
||||
|
||||
---
|
||||
|
||||
## Honest Assessment: Is IHP a Good Framework for Domain Hubs?
|
||||
|
||||
**Yes, with caveats.**
|
||||
|
||||
**Strengths:**
|
||||
- Type safety catches integration errors at compile time, not at 2am
|
||||
- Server-rendered HSX views are fast to write once you know IHP conventions
|
||||
- The query builder and auto-generated types eliminate a whole class of SQL bugs
|
||||
- IHP's code generator scaffolds a controller+4 views in seconds
|
||||
- Once the Nix environment is set up, it is reproducible — no "works on my machine"
|
||||
|
||||
**Caveats:**
|
||||
- The initial Nix setup is still painful on a new machine (~1h)
|
||||
- GHC error messages for type inference failures are dense
|
||||
- No hot-reload for Haskell (GHCi restart is fast, but not instant)
|
||||
- The `hub-core` shared library is planned but not yet implemented —
|
||||
each new hub currently duplicates boilerplate for API client setup,
|
||||
hub registration, and event posting
|
||||
|
||||
**Bottom line:** If you are already comfortable with Haskell and IHP,
|
||||
building domain hubs in the same stack is efficient and the type safety
|
||||
pays dividends quickly. If your team is not Haskell-native, Pattern A
|
||||
(API consumer) is the pragmatic choice — the API surface is stable and
|
||||
well-documented, and you can add a lightweight web layer in whatever
|
||||
language fits your team.
|
||||
|
||||
---
|
||||
|
||||
## What hub-core Would Provide
|
||||
|
||||
The planned `hub-core` Haskell library (not yet implemented) would give
|
||||
every domain hub:
|
||||
|
||||
- `HubRegistration` typeclass — register with inter-hub on startup
|
||||
- `WidgetEnvelope` helpers — consistent widget wrapping across hubs
|
||||
- `InterHubClient` — typed API client with retry and auth built in
|
||||
- `HubCapabilityManifest` bootstrap — auto-activate manifest on startup
|
||||
(planned; use the API recipe above today)
|
||||
- Shared `defaultLayout` with inter-hub navigation integration
|
||||
|
||||
Until `hub-core` exists, copy the client helper above and the 3-step
|
||||
registration pattern into your new hub. It is ~50 lines of boilerplate.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for a New Hub
|
||||
|
||||
- [ ] Start with an existing operator API key
|
||||
- [ ] Create ApiConsumer + ApiKey through `/api/v2/api-consumers`
|
||||
- [ ] Record your hub ID and API key in the new hub's `.env`
|
||||
- [ ] Register HubCapabilityManifest with domain type vocabulary through `/api/v2/hub-capability-manifests`
|
||||
- [ ] Activate the manifest through `/api/v2/hub-capability-manifests/<id>/activate`
|
||||
- [ ] Create at least one Widget per meaningful UI surface
|
||||
- [ ] Instrument interactions with POST to `/api/v2/interaction-events`
|
||||
- [ ] Verify events appear in inter-hub at `/InteractionEvents`
|
||||
- [ ] Run `scripts/ops-hub-bootstrap-smoke.py` against a disposable or staging
|
||||
environment before adapting the recipe for another VSM hub
|
||||
- [ ] (Optional) Configure AgentRegistration and ModelRoutingPolicy for
|
||||
AI-assisted requirement drafting
|
||||
- [ ] (Optional) Set up HubRoutingRules to route annotations to your hub's
|
||||
triage queue
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Location |
|
||||
|----------|----------|
|
||||
| API reference (OpenAPI) | `$IHUB_BASE/api/v2/openapi.json` |
|
||||
| Swagger UI | `$IHUB_BASE/api/v2/docs` |
|
||||
| Type registry browser | `$IHUB_BASE/TypeRegistries/WidgetTypes` |
|
||||
| Domain hub extension guide | `docs/domain-hub-extension-guide.md` |
|
||||
| IHP data and queries | `docs/ihp-data-and-queries.md` |
|
||||
| IHP controllers and views | `docs/ihp-controllers-views-forms.md` |
|
||||
| Functional module maturity | `docs/functional-modules.md` |
|
||||
| IHF v0.2 specification | `specs/InteractionHubFrameworkSpecification_v0.2.md` |
|
||||
157
docs/research/ops-hub-evidence-intake-current-state.md
Normal file
157
docs/research/ops-hub-evidence-intake-current-state.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Ops Hub Evidence Intake - Current State
|
||||
|
||||
Date: 2026-06-15
|
||||
|
||||
Workplan: `IHUB-WP-0022`
|
||||
|
||||
## Summary
|
||||
|
||||
Inter-Hub has the generic v2 API surface needed for activity-core evidence
|
||||
intake, but the activity-core path is not live yet. The safe implementation
|
||||
slice is therefore contract-first:
|
||||
|
||||
- document the ops-hub widget mapping shape;
|
||||
- document the Inter-Hub event payload shape;
|
||||
- keep `OPS_HUB_KEY` outside Git;
|
||||
- accept State Hub fallback as the temporary safety path;
|
||||
- wait on live ops-hub manifest/widgets, key provisioning, and production
|
||||
smoke before enabling per-entity Inter-Hub submission.
|
||||
|
||||
## Inter-Hub API Surface
|
||||
|
||||
The current repo supports the necessary primitives through `/api/v2`.
|
||||
|
||||
`Web.Controller.Api.V2.Widgets`:
|
||||
|
||||
- `GET /api/v2/widgets` is authenticated and paginated.
|
||||
- `POST /api/v2/widgets` requires `hubId`, `name`, and `widgetType`.
|
||||
- Optional fields are `capabilityRef`, `viewContext`, `policyScope`,
|
||||
`status`, and `adapterSpecId`.
|
||||
- `policyScope` defaults to `internal` when omitted.
|
||||
- Valid widget statuses are `active`, `deprecated`, and `draft`.
|
||||
- Widget type and policy scope are validated through the type registries.
|
||||
- Widget creation creates an initial `WidgetVersion` snapshot.
|
||||
|
||||
`Web.Controller.Api.V2.InteractionEvents`:
|
||||
|
||||
- `GET /api/v2/interaction-events` is authenticated and paginated.
|
||||
- Supported list filters are `widgetId` and `eventType`.
|
||||
- `POST /api/v2/interaction-events` requires `widgetId` and `eventType`.
|
||||
- `viewContext` is optional and is persisted as `viewContextRef`.
|
||||
- `metadata` is accepted as a JSON object when the request content type is
|
||||
`application/json`.
|
||||
- The event type must exist in `event_type_registry`.
|
||||
- If the API consumer is bound to an active manifest, the event type must also
|
||||
be declared by that manifest.
|
||||
- `occurredAt` is server-set. Activity-core should send its observed timestamp
|
||||
inside `metadata.attributes.observed_at`.
|
||||
- Actor attribution is `actorType = "api"` for this endpoint.
|
||||
|
||||
`docs/new-hub-quickstart.md` and `scripts/ops-hub-bootstrap-smoke.py` already
|
||||
show the bootstrap shape for a single ops-hub endpoint event. The activity-core
|
||||
intake needs that pattern expanded from one smoke widget/event to a durable
|
||||
five-event contract.
|
||||
|
||||
## Activity-Core Contract
|
||||
|
||||
The neighboring `activity-core` repo already defines the intended event
|
||||
vocabulary under `event-types/`:
|
||||
|
||||
- `ops-service-observed`
|
||||
- `ops-endpoint-verified`
|
||||
- `ops-access-path-checked`
|
||||
- `ops-backup-verified`
|
||||
- `ops-inventory-drift`
|
||||
|
||||
The current activity-core sink implementation is intentionally conservative:
|
||||
|
||||
- `state-hub-progress` is implemented and idempotent.
|
||||
- It posts `ops_inventory_probe` progress with compact non-secret detail.
|
||||
- The idempotency key is `activity_core_run_id + context_key + event_type`.
|
||||
- The compact probe strips raw response bodies, headers, credentials, URL query
|
||||
strings, and token-like material.
|
||||
- Inter-Hub sink names are recognized, but the sink currently returns
|
||||
`missing_inter_hub_config` or `inter_hub_sink_deferred`; it does not submit
|
||||
events yet.
|
||||
- Inter-Hub mode requires `INTER_HUB_URL`, `OPS_HUB_KEY`, and either
|
||||
`OPS_HUB_WIDGET_MAPPING`, `widget_mapping`, or `capability_mapping`.
|
||||
|
||||
Activity-core deployment placeholders exist in
|
||||
`activity-core/k8s/railiance/20-runtime.yaml`:
|
||||
|
||||
- `INTER_HUB_URL` is present but empty.
|
||||
- `OPS_HUB_WIDGET_MAPPING` is present but empty.
|
||||
- `OPS_HUB_KEY` is created only as an empty Secret placeholder by
|
||||
`bootstrap-secrets.sh`.
|
||||
|
||||
## Fallback Evidence State
|
||||
|
||||
State Hub was queried directly for live fallback evidence:
|
||||
|
||||
```text
|
||||
GET http://127.0.0.1:8000/progress/?event_type=ops_inventory_probe&limit=20
|
||||
```
|
||||
|
||||
Result on 2026-06-15: an empty list.
|
||||
|
||||
That means the fallback sink is implemented and tested in activity-core, but no
|
||||
live `ops_inventory_probe` progress event is available for Inter-Hub to accept
|
||||
as closure evidence yet.
|
||||
|
||||
## Production Gates
|
||||
|
||||
Known gates before per-entity Inter-Hub submission can be treated as live:
|
||||
|
||||
1. The production Inter-Hub deployment must include commit `5101eb5` or an
|
||||
equivalent fix for PostgreSQL `COUNT(*)` decoding in widget creation and
|
||||
API rate-limit reads.
|
||||
2. The active `ops-hub` manifest must declare the five activity-core event
|
||||
types, the selected widget types, the annotation category, and the policy
|
||||
scope.
|
||||
3. Seed widgets named by the mapping contract must exist in the target
|
||||
environment.
|
||||
4. `OPS_HUB_KEY` must be provisioned outside Git, preferably in OpenBao at
|
||||
`platform/operators/ops-hub/runtime`, field `OPS_HUB_KEY`.
|
||||
5. Activity-core must receive `INTER_HUB_URL`, `OPS_HUB_KEY`, and
|
||||
`OPS_HUB_WIDGET_MAPPING` through its runtime config/Secret path.
|
||||
6. A controlled smoke must submit one event for each declared event type and
|
||||
verify that an undeclared event type is rejected.
|
||||
|
||||
## Recommended Manifest Vocabulary
|
||||
|
||||
Use one policy scope for the first slice:
|
||||
|
||||
- `ops-evidence`
|
||||
|
||||
Use one annotation category:
|
||||
|
||||
- `ops-risk`
|
||||
|
||||
Use these widget types unless the operator prefers to keep a smaller aggregate
|
||||
surface:
|
||||
|
||||
- `ops-service-card`
|
||||
- `ops-endpoint-card`
|
||||
- `ops-access-path-card`
|
||||
- `ops-backup-card`
|
||||
- `ops-drift-card`
|
||||
|
||||
Use the activity-core event types exactly as published:
|
||||
|
||||
- `ops-service-observed`
|
||||
- `ops-endpoint-verified`
|
||||
- `ops-access-path-checked`
|
||||
- `ops-backup-verified`
|
||||
- `ops-inventory-drift`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Does ops-hub already have a production manifest that should be patched rather
|
||||
than replaced?
|
||||
- Should the first production mapping use only aggregate widgets, or seed
|
||||
per-entity widgets for the known Railiance inventory?
|
||||
- Which OpenBao or cluster Secret path should activity-core consume for
|
||||
`OPS_HUB_KEY`?
|
||||
- Should activity-core close `ACTIVITY-WP-0007/T06` after a live State Hub
|
||||
fallback event with explicit Inter-Hub deferral, or only after real
|
||||
Inter-Hub submission?
|
||||
91
flake.nix
91
flake.nix
@@ -18,7 +18,7 @@
|
||||
systems = import systems;
|
||||
imports = [ ihp.flakeModules.default ];
|
||||
|
||||
perSystem = { pkgs, ... }: {
|
||||
perSystem = { pkgs, config, lib, ... }: {
|
||||
ihp = {
|
||||
appName = "inter-hub";
|
||||
enable = true;
|
||||
@@ -77,6 +77,12 @@
|
||||
# static.makeBundling = true; # Set false if not using Makefile for CSS/JS bundling
|
||||
};
|
||||
|
||||
# OCI container image for Kubernetes deployment (Railiance01).
|
||||
# Build: nix build .#docker
|
||||
# Push: skopeo copy docker-archive:result docker://92.205.130.254:32166/coulomb/inter-hub:SHA
|
||||
# Uses IHP's built-in unoptimized image; binary is /bin/RunProdServer.
|
||||
packages.docker = config.packages.unoptimized-docker-image;
|
||||
|
||||
# Custom configuration that will start with `devenv up`
|
||||
devenv.shells.default = {
|
||||
# Start Mailhog on local development to catch outgoing emails
|
||||
@@ -85,6 +91,89 @@
|
||||
# PostgreSQL extensions
|
||||
# services.postgres.extensions = extensions: [ extensions.postgis ];
|
||||
|
||||
# GHC 9.10.3 crash fix: Generated.ActualTypes uses `module M` re-export
|
||||
# syntax for 61 sub-modules; the resulting ActualTypes.hi exceeds GHC's
|
||||
# ~274 MB binary-deserialization limit (crash at position 287686318).
|
||||
#
|
||||
# pkgs is built from `import nixpkgs { overlays = devenv.shells.default.overlays; }`.
|
||||
# IHP adds ihp.overlays.default to this list, which sets
|
||||
# pkgs.ghc = haskellPackages.override { overrides = ihpOverrides }.
|
||||
# We extend pkgs.ghc with a mkDerivation override (lib.mkAfter ensures
|
||||
# we run after IHP's overlay, so prev.ghc is already IHP's package set).
|
||||
# For inter-hub-models: rewrite ActualTypes.hs export list from (module N)
|
||||
# syntax to explicit T(..) re-exports — keeps hub functional for qualified
|
||||
# references (Generated.ActualTypes.T) while producing compact .hi.
|
||||
# Generated.Types stubbed; inter-hub-lib replaces its import with direct
|
||||
# entity imports (sourceRoot is per-package, originals intact there).
|
||||
overlays = lib.mkAfter [
|
||||
(final: prev: {
|
||||
ghc = prev.ghc.extend (hfinal: hprev: {
|
||||
mkDerivation = args:
|
||||
let drv = hprev.mkDerivation args;
|
||||
in if (args.pname or "") == "inter-hub-models"
|
||||
then drv.overrideAttrs (old: {
|
||||
# GHC 9.10.3 crash: `module M` re-export syntax in Generated.ActualTypes
|
||||
# embeds 61 full sub-interfaces into ActualTypes.hi (~287 MB), exceeding
|
||||
# GHC's 274 MB binary read limit.
|
||||
#
|
||||
# Fix: rewrite the hub's export list from `module M` syntax to explicit
|
||||
# T(..) re-exports. Explicit re-exports store only name references in
|
||||
# the .hi file (compact); `module M` embeds the full sub-interface.
|
||||
# Hub stays functional (consumers still qualify via Generated.ActualTypes),
|
||||
# but .hi stays small.
|
||||
configureFlags = (old.configureFlags or []) ++ [
|
||||
"--ghc-option=-O0"
|
||||
"--ghc-option=-fomit-interface-pragmas"
|
||||
"--disable-split-sections"
|
||||
"--ghc-option=-j1"
|
||||
# GHC 9.10.3 bug: libHSghc-9.10.3-5702.a is truncated (last AR
|
||||
# entry Expr.o claims 517544 bytes but only 82258 remain).
|
||||
# GHC's internal static linker (readAr via Data.Binary.Get) panics
|
||||
# after all 477 modules compile when it flushes deferred symbol
|
||||
# loads from IHP's TH splices that transitively need the ghc pkg.
|
||||
# Fix: delegate TH evaluation to ghc-iserv-dyn, which uses dlopen
|
||||
# on libHSghc.so (intact) instead of readAr on the truncated .a.
|
||||
# ghc-iserv-dyn is not in ghc-with-packages/bin/, so use -pgmi
|
||||
# with the absolute path in the unwrapped GHC store path.
|
||||
"--ghc-option=-fexternal-interpreter"
|
||||
"--ghc-option=-pgmi"
|
||||
"--ghc-option=${hprev.ghc}/lib/ghc-9.10.3/bin/ghc-iserv-dyn"
|
||||
];
|
||||
postUnpack = (old.postUnpack or "") + ''
|
||||
_actual="$sourceRoot/build/Generated/ActualTypes.hs"
|
||||
|
||||
# Rewrite hub export list: (module N, ...) → explicit names.
|
||||
# IHP pattern: data Foo' params = Foo {...} (primed type, unprimed ctor)
|
||||
# type Foo = Foo' arg1 arg2 (concrete alias, kind *)
|
||||
# ADTs: export T(..) to include type + ctor + fields.
|
||||
# type aliases: export T (no (..) — not an ADT).
|
||||
# type instance lines start with lowercase 'i', so don't match [A-Z].
|
||||
_types=$(
|
||||
{
|
||||
awk '/^data [A-Z]|^newtype [A-Z]/{print $2"(..)"}
|
||||
/^type [A-Z]/{print $2}' \
|
||||
"$sourceRoot/build/Generated/Enums.hs"
|
||||
find "$sourceRoot/build/Generated/ActualTypes" -name "*.hs" | \
|
||||
sort | while IFS= read -r _m; do
|
||||
awk '/^data [A-Z]|^newtype [A-Z]/{print $2"(..)"}
|
||||
/^type [A-Z]/{print $2}' "$_m"
|
||||
done
|
||||
} | sort -u
|
||||
)
|
||||
_exports=$(echo "$_types" | \
|
||||
awk 'NR==1{printf " %s", $0; next} {printf "\n , %s", $0} END{printf "\n"}')
|
||||
_imports=$(awk '/^import Generated\./{print}' "$_actual")
|
||||
{
|
||||
printf 'module Generated.ActualTypes\n ( %s ) where\n' "$_exports"
|
||||
printf '%s\n' "$_imports"
|
||||
} > "$_actual.new" && mv "$_actual.new" "$_actual"
|
||||
'';
|
||||
})
|
||||
else drv;
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
# Resource limits for constrained host (2 CPU, ~3.8 GiB RAM).
|
||||
# -A32m: smaller minor heap (reduces GC pressure).
|
||||
# -M2g: hard heap ceiling (prevents OOM on large compiles).
|
||||
|
||||
12
registry/README.md
Normal file
12
registry/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Capability Registry
|
||||
|
||||
Markdown-first capability index for federation and reuse planning.
|
||||
|
||||
## Authoring
|
||||
|
||||
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
|
||||
2. Add the row to `indexes/capabilities.yaml`.
|
||||
3. Run `reuse-surface validate` from a checkout with the CLI installed.
|
||||
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
|
||||
|
||||
Federation contract: reuse-surface `docs/RegistryFederation.md`.
|
||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal file
4
registry/indexes/capabilities.yaml
Normal file
4
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
version: 1
|
||||
updated: '2026-06-16'
|
||||
domain: helix_forge
|
||||
capabilities: []
|
||||
270
scripts/ops-hub-bootstrap-smoke.py
Executable file
270
scripts/ops-hub-bootstrap-smoke.py
Executable file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke-test the v2 ops-hub bootstrap path.
|
||||
|
||||
Required environment:
|
||||
IHUB_OPERATOR_KEY Existing operator/admin API key.
|
||||
|
||||
Optional environment:
|
||||
IHUB_BASE Inter-Hub base URL. Default: http://127.0.0.1:8000
|
||||
OPS_HUB_SLUG Hub slug to create or reuse. Default: ops-hub
|
||||
OPS_HUB_NAME Hub display name. Default: Operations Hub
|
||||
OPS_HUB_DOMAIN Hub domain. Default: operations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_URL = os.environ.get("IHUB_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
OPERATOR_KEY = os.environ.get("IHUB_OPERATOR_KEY", "")
|
||||
|
||||
HUB_SLUG = os.environ.get("OPS_HUB_SLUG", "ops-hub")
|
||||
HUB_NAME = os.environ.get("OPS_HUB_NAME", "Operations Hub")
|
||||
HUB_DOMAIN = os.environ.get("OPS_HUB_DOMAIN", "operations")
|
||||
|
||||
WIDGET_TYPE = "ops-endpoint-card"
|
||||
EVENT_TYPE = "ops-endpoint-verified"
|
||||
ANNOTATION_CATEGORY = "ops-risk"
|
||||
POLICY_SCOPE = "ops-internal"
|
||||
WIDGET_NAME = "CoulombCore Gitea Registry"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not OPERATOR_KEY:
|
||||
print("IHUB_OPERATOR_KEY is required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
hub = ensure_hub()
|
||||
manifest = ensure_manifest(hub["id"])
|
||||
key_response = create_runtime_key(manifest["id"])
|
||||
runtime_key = key_response["fullKey"]
|
||||
widget = ensure_widget(runtime_key, hub["id"])
|
||||
event = submit_event(runtime_key, widget["id"])
|
||||
verify_event(runtime_key, widget["id"], event["id"])
|
||||
|
||||
print(json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"hubId": hub["id"],
|
||||
"manifestId": manifest["id"],
|
||||
"apiConsumerId": key_response["apiConsumer"]["id"],
|
||||
"apiKeyPrefix": key_response["apiKey"]["keyPrefix"],
|
||||
"widgetId": widget["id"],
|
||||
"eventId": event["id"],
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
))
|
||||
return 0
|
||||
|
||||
|
||||
def ensure_hub() -> dict[str, Any]:
|
||||
existing = find_by(list_items("/api/v2/hubs", None), "slug", HUB_SLUG)
|
||||
if existing:
|
||||
print(f"reusing hub {HUB_SLUG} ({existing['id']})", file=sys.stderr)
|
||||
return existing
|
||||
|
||||
return request_json(
|
||||
"POST",
|
||||
"/api/v2/hubs",
|
||||
OPERATOR_KEY,
|
||||
{
|
||||
"slug": HUB_SLUG,
|
||||
"name": HUB_NAME,
|
||||
"domain": HUB_DOMAIN,
|
||||
"hubKind": "domain",
|
||||
"hubFamily": "vsm",
|
||||
"vsmFunction": "operations",
|
||||
"vsmSystem": "1",
|
||||
},
|
||||
expected={201},
|
||||
)
|
||||
|
||||
|
||||
def ensure_manifest(hub_id: str) -> dict[str, Any]:
|
||||
manifests = list_items(
|
||||
"/api/v2/hub-capability-manifests?"
|
||||
+ urllib.parse.urlencode({"hubId": hub_id}),
|
||||
OPERATOR_KEY,
|
||||
)
|
||||
active = first(lambda item: item.get("status") == "active", manifests)
|
||||
if active:
|
||||
print(f"reusing active manifest {active['id']}", file=sys.stderr)
|
||||
return active
|
||||
|
||||
body = {
|
||||
"manifestVersion": "1.0",
|
||||
"declaredWidgetTypes": [WIDGET_TYPE],
|
||||
"declaredEventTypes": [EVENT_TYPE],
|
||||
"declaredAnnotationCategories": [ANNOTATION_CATEGORY],
|
||||
"declaredPolicyScopes": [POLICY_SCOPE],
|
||||
"capabilityDescription": "Operations inventory and endpoint verification",
|
||||
"contact": "ops@example.com",
|
||||
}
|
||||
draft = first(lambda item: item.get("status") == "draft", manifests)
|
||||
if draft:
|
||||
manifest = request_json(
|
||||
"PATCH",
|
||||
f"/api/v2/hub-capability-manifests/{draft['id']}",
|
||||
OPERATOR_KEY,
|
||||
body,
|
||||
expected={200},
|
||||
)
|
||||
else:
|
||||
manifest = request_json(
|
||||
"POST",
|
||||
"/api/v2/hub-capability-manifests",
|
||||
OPERATOR_KEY,
|
||||
{"hubId": hub_id, **body},
|
||||
expected={201},
|
||||
)
|
||||
|
||||
return request_json(
|
||||
"POST",
|
||||
f"/api/v2/hub-capability-manifests/{manifest['id']}/activate",
|
||||
OPERATOR_KEY,
|
||||
None,
|
||||
expected={200},
|
||||
)
|
||||
|
||||
|
||||
def create_runtime_key(manifest_id: str) -> dict[str, Any]:
|
||||
run_id = int(time.time())
|
||||
consumer = request_json(
|
||||
"POST",
|
||||
"/api/v2/api-consumers",
|
||||
OPERATOR_KEY,
|
||||
{
|
||||
"name": f"{HUB_SLUG}-smoke-{run_id}",
|
||||
"description": "ops-hub bootstrap smoke test runtime client",
|
||||
"hubCapabilityManifestId": manifest_id,
|
||||
"rateLimitPerMinute": 120,
|
||||
"quotaPerDay": 50000,
|
||||
},
|
||||
expected={201},
|
||||
)
|
||||
key_response = request_json(
|
||||
"POST",
|
||||
f"/api/v2/api-consumers/{consumer['id']}/api-keys",
|
||||
OPERATOR_KEY,
|
||||
{"scopes": "ops:write"},
|
||||
expected={201},
|
||||
)
|
||||
if not key_response.get("fullKey"):
|
||||
raise RuntimeError("api key creation did not return display-once fullKey")
|
||||
return {"apiConsumer": consumer, **key_response}
|
||||
|
||||
|
||||
def ensure_widget(runtime_key: str, hub_id: str) -> dict[str, Any]:
|
||||
widgets = list_items("/api/v2/widgets", runtime_key)
|
||||
existing = first(
|
||||
lambda item: item.get("hubId") == hub_id and item.get("name") == WIDGET_NAME,
|
||||
widgets,
|
||||
)
|
||||
if existing:
|
||||
print(f"reusing widget {WIDGET_NAME} ({existing['id']})", file=sys.stderr)
|
||||
return existing
|
||||
|
||||
return request_json(
|
||||
"POST",
|
||||
"/api/v2/widgets",
|
||||
runtime_key,
|
||||
{
|
||||
"hubId": hub_id,
|
||||
"name": WIDGET_NAME,
|
||||
"widgetType": WIDGET_TYPE,
|
||||
"viewContext": "operations-inventory",
|
||||
"policyScope": POLICY_SCOPE,
|
||||
"status": "active",
|
||||
},
|
||||
expected={201},
|
||||
)
|
||||
|
||||
|
||||
def submit_event(runtime_key: str, widget_id: str) -> dict[str, Any]:
|
||||
return request_json(
|
||||
"POST",
|
||||
"/api/v2/interaction-events",
|
||||
runtime_key,
|
||||
{
|
||||
"widgetId": widget_id,
|
||||
"eventType": EVENT_TYPE,
|
||||
"viewContext": "registry-readiness",
|
||||
"metadata": {
|
||||
"service": "gitea",
|
||||
"endpoint": "https://gitea.coulomb.social/v2/",
|
||||
"result": "auth-challenge-ok",
|
||||
"smokeRunAt": int(time.time()),
|
||||
},
|
||||
},
|
||||
expected={201},
|
||||
)
|
||||
|
||||
|
||||
def verify_event(runtime_key: str, widget_id: str, event_id: str) -> None:
|
||||
query = urllib.parse.urlencode({"widgetId": widget_id, "eventType": EVENT_TYPE})
|
||||
events = list_items(f"/api/v2/interaction-events?{query}", runtime_key)
|
||||
if not any(item.get("id") == event_id for item in events):
|
||||
raise RuntimeError(f"created event {event_id} was not returned by list endpoint")
|
||||
|
||||
|
||||
def list_items(path: str, token: str | None) -> list[dict[str, Any]]:
|
||||
response = request_json("GET", path, token, None, expected={200})
|
||||
data = response.get("data", [])
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError(f"expected paginated data array from {path}")
|
||||
return data
|
||||
|
||||
|
||||
def request_json(
|
||||
method: str,
|
||||
path: str,
|
||||
token: str | None,
|
||||
body: dict[str, Any] | None,
|
||||
*,
|
||||
expected: set[int],
|
||||
) -> dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(BASE_URL + path, data=data, method=method)
|
||||
if token is not None:
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
if body is not None:
|
||||
request.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
status = response.status
|
||||
payload = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
payload = error.read().decode("utf-8")
|
||||
raise RuntimeError(f"{method} {path} failed with HTTP {error.code}: {payload}") from error
|
||||
|
||||
if status not in expected:
|
||||
raise RuntimeError(f"{method} {path} returned HTTP {status}, expected {sorted(expected)}: {payload}")
|
||||
if not payload:
|
||||
return {}
|
||||
return json.loads(payload)
|
||||
|
||||
|
||||
def find_by(items: list[dict[str, Any]], key: str, value: Any) -> dict[str, Any] | None:
|
||||
return first(lambda item: item.get(key) == value, items)
|
||||
|
||||
|
||||
def first(predicate, items):
|
||||
for item in items:
|
||||
if predicate(item):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
26
workplans/ADHOC-2026-06-06.md
Normal file
26
workplans/ADHOC-2026-06-06.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: ADHOC-2026-06-06
|
||||
type: workplan
|
||||
title: "Ad hoc fixes for 2026-06-06"
|
||||
domain: custodian
|
||||
repo: inter-hub
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: inter_hub
|
||||
created: "2026-06-06"
|
||||
updated: "2026-06-06"
|
||||
---
|
||||
|
||||
# ADHOC-2026-06-06 - Ad hoc fixes for 2026-06-06
|
||||
|
||||
## Make local UI setup targets self-explanatory
|
||||
|
||||
```task
|
||||
id: ADHOC-2026-06-06-T01
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Added default `make` help plus `install`, `install-nix`, `doctor`, and `ui`
|
||||
setup guidance so local UI bootstrap reports missing or partially configured
|
||||
Nix/devenv clearly.
|
||||
72
workplans/ADHOC-2026-06-15.md
Normal file
72
workplans/ADHOC-2026-06-15.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
id: ADHOC-2026-06-15
|
||||
type: workplan
|
||||
title: "Ad hoc Inter-Hub production fixes"
|
||||
domain: custodian
|
||||
repo: inter-hub
|
||||
status: active
|
||||
owner: codex
|
||||
created: "2026-06-15"
|
||||
updated: "2026-06-15"
|
||||
state_hub_workstream_id: "9e7a50b4-da7f-4df9-9154-7b89a071f520"
|
||||
---
|
||||
|
||||
# Ad hoc Inter-Hub production fixes
|
||||
|
||||
## Fix COUNT decode failures in v2 bootstrap endpoints
|
||||
|
||||
```task
|
||||
id: ADHOC-2026-06-15-T01
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "cceee9f1-56af-44bc-898d-21c4508df07c"
|
||||
```
|
||||
|
||||
Production Ops Hub bootstrap exposed a PostgreSQL/Haskell type mismatch in
|
||||
the v2 API helpers. `COUNT(*)` returns `bigint`, while the helper code decoded
|
||||
the result as `Int`, causing `UnexpectedColumnTypeStatementError` in widget
|
||||
type validation and API request log rate-limit checks.
|
||||
|
||||
Fix the count queries so widget creation and authenticated hub-registry reads
|
||||
work through the documented v2 bootstrap API.
|
||||
|
||||
Source fix on 2026-06-15:
|
||||
|
||||
- `Application/Helper/TypeRegistry.hs` now casts registry validation
|
||||
`COUNT(*)` queries to `int`.
|
||||
- `Application/Helper/ApiRateLimit.hs` now casts API request log
|
||||
`COUNT(*)` queries to `int`.
|
||||
- Commit `5101eb5 Fix API count decoding` was pushed to `origin/main`.
|
||||
|
||||
Blocked before live completion:
|
||||
|
||||
- The Gitea deploy workflow did not update production during the session.
|
||||
- Production still reports image `gitea.coulomb.social/coulomb/inter-hub:5c13de1`.
|
||||
- Local `nix develop ... scripts/compile-check` is blocked by local devenv
|
||||
setup, and the local `nix build .#docker` remained in dependency compilation
|
||||
after more than 20 minutes. The build was stopped cleanly.
|
||||
|
||||
Deploy trigger attempt on 2026-06-15:
|
||||
|
||||
- Confirmed current `main` contains the COUNT decode fix and is at commit
|
||||
`f8fde35`.
|
||||
- Confirmed the deploy workflow is the normal path and is pinned to
|
||||
`runs-on: [self-hosted, haskelseed]`.
|
||||
- Confirmed image tag `gitea.coulomb.social/coulomb/inter-hub:f8fde35`
|
||||
returns `manifest unknown`.
|
||||
- Gitea Actions API inspection/dispatch was attempted using the locally
|
||||
configured `tea` token, but the public HTTPS API returned `401 Unauthorized`
|
||||
for Actions endpoints; the raw configured HTTP endpoint was not reachable
|
||||
from this session.
|
||||
- Pushed empty commit `68c66b9` (`chore: trigger inter-hub deploy`) because
|
||||
the previous contract/docs commit was ignored by the deploy workflow's
|
||||
`paths-ignore` rules.
|
||||
- Polled the registry for
|
||||
`gitea.coulomb.social/coulomb/inter-hub:68c66b9` for about five minutes
|
||||
after push; it continued to return `manifest unknown`.
|
||||
|
||||
Current wait reason: the source fix is pushed, but image publication/deploy now
|
||||
requires authenticated Gitea Actions workflow dispatch or inspection of the
|
||||
self-hosted `haskelseed` runner path. The normal workflow needs haskelseed as
|
||||
build runner; an equivalent operator-controlled build host with Nix, registry
|
||||
push credentials, and Railiance deploy credentials could substitute.
|
||||
@@ -4,11 +4,12 @@ type: workplan
|
||||
title: "IHF Phase 9 — External API Surface and Consumer SDKs"
|
||||
domain: inter_hub
|
||||
repo: inter-hub
|
||||
status: active
|
||||
status: done
|
||||
owner: custodian
|
||||
topic_slug: inter_hub
|
||||
created: "2026-04-01"
|
||||
updated: "2026-04-01"
|
||||
updated: "2026-06-07"
|
||||
completed: "2026-06-07"
|
||||
state_hub_sync: done
|
||||
state_hub_workstream_id: "c6c6e87f-e145-4bc4-9881-61f92b14d4de"
|
||||
---
|
||||
@@ -68,6 +69,12 @@ Schema additions:
|
||||
- `webhook_deliveries` table
|
||||
- `api_request_log` table (for usage dashboard and rate limiting)
|
||||
|
||||
## Close-out Correction - 2026-06-07
|
||||
|
||||
State Hub showed IHUB-WP-0010 as active even though all eleven task rows were
|
||||
already `done`. The workplan frontmatter and final exit checklist were corrected
|
||||
to reflect the completed Phase 9 state.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
@@ -748,17 +755,17 @@ Enforce per-consumer limits and close out the workplan.
|
||||
|
||||
**Exit criteria (Phase 9 complete when all of these are true):**
|
||||
|
||||
- [ ] All core IHF artifact types are readable via `/api/v2/`
|
||||
- [ ] Interaction events and annotations are writable via `/api/v2/`
|
||||
- [ ] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
|
||||
- [x] All core IHF artifact types are readable via `/api/v2/`
|
||||
- [x] Interaction events and annotations are writable via `/api/v2/`
|
||||
- [x] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
|
||||
`enum` arrays from live registries
|
||||
- [ ] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
|
||||
- [ ] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
|
||||
- [ ] Webhook delivery confirmed for `interaction_event.created` and
|
||||
- [x] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
|
||||
- [x] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
|
||||
- [x] Webhook delivery confirmed for `interaction_event.created` and
|
||||
`requirement_candidate.created`
|
||||
- [ ] API usage dashboard renders correctly with AutoRefresh
|
||||
- [ ] OAuth client credentials flow works end-to-end
|
||||
- [ ] Submission of an unregistered `event_type` returns HTTP 422 with
|
||||
- [x] API usage dashboard renders correctly with AutoRefresh
|
||||
- [x] OAuth client credentials flow works end-to-end
|
||||
- [x] Submission of an unregistered `event_type` returns HTTP 422 with
|
||||
registry-referenced error
|
||||
- [ ] Rate limiting returns 429 with `Retry-After`
|
||||
- [ ] CLAUDE.md updated; IHUB-WP-0010 listed as complete
|
||||
- [x] Rate limiting returns 429 with `Retry-After`
|
||||
- [x] CLAUDE.md updated; IHUB-WP-0010 listed as complete
|
||||
|
||||
596
workplans/IHUB-WP-0018-railiance01-deployment.md
Normal file
596
workplans/IHUB-WP-0018-railiance01-deployment.md
Normal file
@@ -0,0 +1,596 @@
|
||||
---
|
||||
id: IHUB-WP-0018
|
||||
type: workplan
|
||||
title: "Railiance01 Deployment — Production Operations Scaffold"
|
||||
domain: inter_hub
|
||||
repo: inter-hub
|
||||
status: finished
|
||||
owner: custodian
|
||||
topic_slug: inter_hub
|
||||
created: "2026-04-29"
|
||||
updated: "2026-06-14"
|
||||
depends_on: IHUB-WP-0015
|
||||
state_hub_workstream_id: "080d841a-3acd-4adf-b684-2d1890a5e986"
|
||||
---
|
||||
|
||||
# IHUB-WP-0018 — Railiance01 Deployment: Production Operations Scaffold
|
||||
|
||||
## Goal
|
||||
|
||||
Deploy inter-hub to the Railiance01 Kubernetes cluster with fully automatic
|
||||
deployment, SOPS-encrypted secrets, Traefik ingress, PostgreSQL HA, and a
|
||||
Gitea Actions CI/CD pipeline. After this workplan, every push to `main`
|
||||
automatically builds an OCI container image on haskelseed, pushes it to the
|
||||
Railiance container registry, and deploys it — with automatic restart on node
|
||||
reboot guaranteed by K3s.
|
||||
|
||||
## Background
|
||||
|
||||
inter-hub v0.2.0-alpha.1 is running on haskelseed (Alpine) via RunDevServer
|
||||
and socat. That setup is a development convenience, not a production operations
|
||||
scaffold. The target is the Railiance01 K3s cluster, which has:
|
||||
|
||||
- K3s (single-node for now; ThreePhoenix HA cluster is in progress)
|
||||
- Traefik ingress with TLS
|
||||
- PostgreSQL HA (repmgr + pgpool) managed by railiance-platform
|
||||
- SOPS/age secret management
|
||||
- Gitea with built-in container registry (or separate registry service)
|
||||
- Staged Promotion Lifecycle CLI (`railiance run / deploy / promote / rollback`)
|
||||
|
||||
**Key constraint:** This workplan depends on Railiance01 K3s being operational.
|
||||
Gate R3 verifies cluster readiness before any deployment work begins — if K3s
|
||||
or the container registry is not ready, this workplan blocks there and the
|
||||
cluster work must be completed first.
|
||||
|
||||
**IHP specifics:** IHP DevServer is a development server. For production we
|
||||
build the IHP binary via `nix build` (which produces a self-contained binary)
|
||||
and wrap it in a minimal OCI image using Nix's `dockerTools.buildImage`. The
|
||||
app serves HTTP on port 8000; the socat workaround is not needed in Kubernetes
|
||||
since Traefik routes directly to the pod's port.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
git push → Gitea Actions
|
||||
→ SSH to haskelseed: nix build → docker load → docker push registry/inter-hub:$SHA
|
||||
→ helm upgrade inter-hub railiance-apps/helm/inter-hub
|
||||
→ Deployment (1 replica): inter-hub:$SHA + env from Secrets
|
||||
→ Service (ClusterIP :8000)
|
||||
→ Ingress (Traefik): hub.coulomb.social → Service
|
||||
→ PersistentVolumeClaim: /app/static (generated CSS/JS)
|
||||
→ PostgreSQL: database 'interhub' on railiance-platform HA cluster
|
||||
```
|
||||
|
||||
## Close-out Audit - 2026-06-04
|
||||
|
||||
WSJF triage flagged this workplan as a close-out candidate because State Hub had
|
||||
no indexed task rows for it. The deployment work is not complete; this file now
|
||||
contains explicit task blocks so the hub can track the remaining Railiance01
|
||||
deployment work instead of treating the workplan as empty.
|
||||
|
||||
## Deployment Review - 2026-06-05
|
||||
|
||||
Review against the current repo and public Railiance endpoint shows the
|
||||
deployment scaffold is partially implemented but the live deployment is behind
|
||||
`origin/main`.
|
||||
|
||||
- `origin/main` is at `a3d980c`, which includes the completed ops-hub bootstrap
|
||||
API work from `IHUB-WP-0019`.
|
||||
- `https://hub.coulomb.social/` returns 200 and serves inter-hub.
|
||||
- The public OpenAPI only lists the older v2 endpoints; it does not include
|
||||
`/hubs`, `/hub-capability-manifests`, `/api-consumers`, or `/policy-scopes`.
|
||||
- Unauthenticated `/api/v2/hubs` returns 404 publicly, while current source
|
||||
should route it and return 401. This means ops-hub bootstrap cannot run
|
||||
against production until the current image is deployed.
|
||||
- The registry endpoint returns the expected unauthenticated `/v2/` 401
|
||||
challenge, but this workspace does not have `kubectl`, so R3 cluster readiness
|
||||
cannot be fully verified from here.
|
||||
|
||||
## Tasks
|
||||
|
||||
### R1 - Add OCI image build to flake.nix
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "27420bd7-0f70-4793-8805-393d8d5cacfd"
|
||||
```
|
||||
Add a `packages.docker` output to `flake.nix` using `pkgs.dockerTools.buildLayeredImage`.
|
||||
The image wraps the IHP production binary produced by `nix build .#default`.
|
||||
|
||||
```nix
|
||||
packages.docker = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "inter-hub";
|
||||
tag = "latest";
|
||||
contents = [ self.packages.${system}.default pkgs.cacert ];
|
||||
config = {
|
||||
Cmd = [ "/bin/inter-hub" ];
|
||||
ExposedPorts = { "8000/tcp" = {}; };
|
||||
Env = [
|
||||
"PORT=8000"
|
||||
"IHP_ENV=Production"
|
||||
];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Test locally on haskelseed:
|
||||
```bash
|
||||
nix build .#docker
|
||||
docker load < result
|
||||
docker run --rm -p 8000:8000 -e DATABASE_URL=... -e IHP_SESSION_SECRET=... inter-hub:latest
|
||||
```
|
||||
|
||||
**Note:** First build pulls the full Haskell binary closure (~2 GB); subsequent
|
||||
builds are incremental (layer caching). Build must run on haskelseed - the only
|
||||
machine with the Nix store populated for GHC 9.10.3.
|
||||
|
||||
**Implementation note (2026-06-05):** `flake.nix` exposes `packages.docker =
|
||||
config.packages.unoptimized-docker-image`, the IHP-provided production OCI
|
||||
image used by the Railiance runbook. The original `buildLayeredImage` sketch is
|
||||
superseded by that IHP image path.
|
||||
|
||||
### R2 — Verify container runs correctly
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5ab45e4e-16bc-4feb-8b1b-e8eeb05bf39a"
|
||||
```
|
||||
On haskelseed, run the container image against the existing `interhub` database.
|
||||
Confirm:
|
||||
- `curl http://localhost:8000/` returns 200 (LandingAction)
|
||||
- `curl http://localhost:8000/api/v2/hubs` returns 200 (public discovery)
|
||||
- Static assets load (Tailwind CSS present in image)
|
||||
- Container exits cleanly on SIGTERM
|
||||
|
||||
If Tailwind CSS output (`static/app.css`) is not bundled into the Nix binary
|
||||
closure, add a pre-build step: run tailwindcss and include `static/` in the
|
||||
image via `dockerTools.buildLayeredImage` `contents` or a NixOS module.
|
||||
|
||||
### R3 — Verify Railiance01 readiness (gate)
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "79b5cf2c-3a5b-4b4b-8f84-f635cb6891c1"
|
||||
```
|
||||
This is a dependency gate. Before proceeding, confirm:
|
||||
|
||||
```bash
|
||||
# From CoulombCore (execution origin):
|
||||
kubectl get nodes # must show Ready
|
||||
kubectl get pods -n kube-system | grep traefik # Traefik must be running
|
||||
kubectl get pods -n railiance-platform # PostgreSQL HA pods
|
||||
```
|
||||
|
||||
Also confirm:
|
||||
- Container registry is reachable from haskelseed (verify push access)
|
||||
- Registry address (e.g., `registry.coulomb.social` or `gitea.coulomb.social`)
|
||||
- SOPS/age key is present on CoulombCore at `~/.config/sops/age/keys.txt`
|
||||
|
||||
If any check fails, block here and open the relevant Railiance workstream.
|
||||
Do not proceed until all checks pass.
|
||||
|
||||
**Review note (2026-06-05):** Public smoke probes show
|
||||
`https://hub.coulomb.social/` returning 200 and the Gitea registry `/v2/`
|
||||
endpoint returning the expected unauthenticated 401 challenge. Full R3 remains
|
||||
blocked from this workspace because `kubectl` is not available here, and the
|
||||
live app is not serving the current `origin/main` v2 bootstrap routes.
|
||||
|
||||
**Recovery note (2026-06-14):** Re-established the haskelseed ops-bridge path
|
||||
and verified the runner substrate before deployment. `make runner-status` in
|
||||
`railiance-forge` confirmed `act_runner` is registered to
|
||||
`https://gitea.coulomb.social`, running under OpenRC, and has the expected
|
||||
self-hosted labels and build/deploy tools. The K3s API path, Helm deploy path,
|
||||
and Gitea registry host were exercised successfully by the production rollout.
|
||||
|
||||
### R4 — Provision inter-hub database on railiance-platform
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c937cf36-3850-4ab3-aa83-2d846e1a378e"
|
||||
```
|
||||
On the PostgreSQL HA cluster, create the inter-hub database and user:
|
||||
|
||||
```sql
|
||||
CREATE USER interhub WITH PASSWORD '<generated>';
|
||||
CREATE DATABASE interhub OWNER interhub;
|
||||
GRANT ALL PRIVILEGES ON DATABASE interhub TO interhub;
|
||||
```
|
||||
|
||||
Run schema migration (IHP migrations) as part of the first deployment via an
|
||||
init container or a manual `migrate` run inside the pod. Document the
|
||||
migration procedure in `deploy/railiance/RUNBOOK.md`.
|
||||
|
||||
**Recovery note (2026-06-14):** Bootstrapped the production database manually on
|
||||
the Railiance PostgreSQL cluster: role `interhub`, database `interhub`, schema
|
||||
ownership, and privileges were created/updated. The running deployment now uses
|
||||
that database through the `inter-hub-env` Kubernetes Secret.
|
||||
|
||||
**Production initialization note (2026-06-14):** After DNS/TLS and network
|
||||
access were restored, production OpenAPI still failed because the `interhub`
|
||||
database was blank (`public_table_count:0`). The IHP production image only
|
||||
contains `RunProdServer` and `RunJobs`, so there was no packaged migration
|
||||
runner to execute. Initialized the database through the CloudNativePG pod by
|
||||
loading `Application/Schema.sql` in one transaction, applying the idempotent
|
||||
type-registry seed migration `1744502400`, and granting app privileges on the
|
||||
new schema to the `interhub` role. The default admin seed with a known password
|
||||
was intentionally not applied to production.
|
||||
|
||||
### R5 — SOPS-encrypted secrets
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "926f82d1-15cd-425d-8a41-3d6b51c07f0b"
|
||||
```
|
||||
Create `deploy/railiance/secrets/inter-hub.env.sops.yaml` with:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: inter-hub-env
|
||||
namespace: inter-hub
|
||||
type: Opaque
|
||||
stringData:
|
||||
DATABASE_URL: postgresql://interhub:<pass>@net-kingdom-pg-rw.databases.svc.cluster.local:5432/interhub?sslmode=disable
|
||||
IHP_SESSION_SECRET: <64-char-hex>
|
||||
IHP_BASEURL: https://hub.coulomb.social
|
||||
PORT: "8000"
|
||||
IHP_ENV: Production
|
||||
```
|
||||
|
||||
Encrypt with the age key:
|
||||
```bash
|
||||
sops --encrypt \
|
||||
--age age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 \
|
||||
/tmp/inter-hub-env.yaml > deploy/railiance/secrets/inter-hub.env.sops.yaml
|
||||
```
|
||||
|
||||
Commit only the encrypted file. Apply it with
|
||||
`sops -d deploy/railiance/secrets/inter-hub.env.sops.yaml | kubectl apply -f -`.
|
||||
|
||||
**Recovery note (2026-06-14):** Runtime secrets were bootstrapped manually in
|
||||
Kubernetes so production could deploy safely. This task remains in progress
|
||||
until the durable SOPS-encrypted source for `DATABASE_URL`, `IHP_SESSION_SECRET`,
|
||||
and related runtime env is committed and wired into the deploy path.
|
||||
|
||||
**Progress note (2026-06-14):** Added repo root `.sops.yaml`, plaintext
|
||||
guardrails under `deploy/railiance/secrets/`, an example Secret manifest, and
|
||||
`k8s-secret-json-to-sops-input.py` to convert the live Kubernetes Secret into a
|
||||
SOPS-ready manifest without printing values. At that point the encrypted source
|
||||
file was still pending because local `sops` tooling was not available.
|
||||
|
||||
**Completion note (2026-06-14):** Created
|
||||
`deploy/railiance/secrets/inter-hub.env.sops.yaml` from the live
|
||||
`inter-hub/inter-hub-env` Kubernetes Secret using temporary `sops` v3.13.1 and
|
||||
the shared Railiance age recipient. Verified the file is SOPS-encrypted, parses
|
||||
as YAML, leaves only non-secret metadata reviewable, and does not contain the
|
||||
checked plaintext runtime markers. Decryption/apply verification remains a
|
||||
custody-backed operator capability because the private age identity is not
|
||||
present in the normal workstation or haskelseed shell.
|
||||
|
||||
### R6 — Helm chart in railiance-apps
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T06
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4c4acc98-5773-4289-ad57-03f3fd5c381c"
|
||||
```
|
||||
Create `charts/inter-hub/` in the `railiance-apps` repository following the
|
||||
Railiance app.toml contract. Minimal chart:
|
||||
|
||||
```
|
||||
charts/inter-hub/
|
||||
Chart.yaml name: inter-hub, version: 0.1.0
|
||||
values.yaml image.tag, ingress.host, resources
|
||||
helm/inter-hub-values.yaml
|
||||
production non-secret overrides
|
||||
templates/
|
||||
deployment.yaml envFrom: secretRef inter-hub-env
|
||||
service.yaml ClusterIP :8000
|
||||
ingress.yaml Traefik annotations, TLS
|
||||
```
|
||||
|
||||
`app.toml` in the inter-hub repo root for railiance CLI integration:
|
||||
```toml
|
||||
[app]
|
||||
name = "inter-hub"
|
||||
slug = "inter-hub"
|
||||
kind = "native"
|
||||
registry = "gitea.coulomb.social/coulomb/inter-hub"
|
||||
|
||||
[deploy]
|
||||
chart = "railiance-apps/charts/inter-hub"
|
||||
namespace = "inter-hub"
|
||||
```
|
||||
|
||||
**Implementation note (2026-06-05):** A Helm chart exists in
|
||||
`deploy/helm/inter-hub/` with Deployment, Service, Ingress, and values for the
|
||||
current Gitea registry and `hub.coulomb.social`. Remaining gaps: no repo-root
|
||||
`app.toml`, no committed SOPS secret manifest, and no separate
|
||||
`railiance-apps/helm/inter-hub` handoff in this repo.
|
||||
|
||||
**Recovery note (2026-06-14):** The local chart under `deploy/helm/inter-hub/`
|
||||
successfully deployed the app to Railiance01. This task remains in progress
|
||||
because the repo-root `app.toml` and railiance-apps handoff are still not
|
||||
completed.
|
||||
|
||||
**Completion note (2026-06-14):** Added repo-root `app.toml` in inter-hub and
|
||||
added `charts/inter-hub`, `helm/inter-hub-values.yaml`, Makefile targets, and
|
||||
server-dry-run coverage in `railiance-apps`. The chart rendered successfully on
|
||||
haskelseed with `helm template`.
|
||||
|
||||
### R7 — Gitea Actions CI/CD pipeline
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T07
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "ec25c67c-3cb0-4534-9fb0-9bd6578a2def"
|
||||
```
|
||||
Create `.gitea/workflows/deploy.yaml` in the inter-hub repo:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest # or self-hosted if available
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build OCI image on haskelseed
|
||||
run: |
|
||||
ssh haskelseed "cd /root/inter-hub && git pull && \
|
||||
nix build .#docker && \
|
||||
docker load < result && \
|
||||
docker tag inter-hub:latest $REGISTRY/inter-hub:${{ github.sha }} && \
|
||||
docker push $REGISTRY/inter-hub:${{ github.sha }}"
|
||||
|
||||
- name: Deploy to Railiance01
|
||||
run: |
|
||||
ssh coulombcore "helm upgrade --install inter-hub \
|
||||
railiance-apps/helm/inter-hub \
|
||||
--namespace inter-hub --create-namespace \
|
||||
--set image.tag=${{ github.sha }} \
|
||||
-f railiance-apps/helm/inter-hub/values.prod.yaml"
|
||||
```
|
||||
|
||||
Secrets in Gitea: `REGISTRY`, `SSH_KEY_HASKELSEED`, `SSH_KEY_COULOMBCORE`.
|
||||
|
||||
**Alternative if self-hosted runner is available on CoulombCore:** run the
|
||||
deploy step directly without the SSH hop to coulombcore.
|
||||
|
||||
**Implementation note (2026-06-05):** `.gitea/workflows/deploy.yaml` exists and
|
||||
builds `.#docker` on a self-hosted `haskelseed` runner, pushes to
|
||||
`92.205.130.254:32166/coulomb/inter-hub`, deploys with Helm, and smoke-tests
|
||||
the public endpoint. Remote `main` is already current, but production is still
|
||||
serving an older API surface, so the workflow needs an attended rerun/inspection
|
||||
or a new deployment trigger.
|
||||
|
||||
**Runner substrate finding (2026-06-07):** Pushed commits `fa96fb8` and
|
||||
`7cc3173` to trigger the workflow, but public `/api/v2/hubs` remained `404`
|
||||
while `/` stayed `200`, indicating the current image was not deployed. Repo
|
||||
search shows `railiance-forge` owns Actions runner substrate, but its
|
||||
2026-06-05 migration plan explicitly lists "No Actions runner deployment" as a
|
||||
non-goal and no runner manifest/script/workplan exists there yet. `haskelseed`
|
||||
itself is reachable on SSH and historical port 8080, but this workspace cannot
|
||||
authenticate non-interactively. Treat R7 as blocked on a forge-owned runner
|
||||
prerequisite rather than continuing to push commits as deployment probes.
|
||||
|
||||
**Recovery note (2026-06-14):** The runner prerequisite was restored through
|
||||
the haskelseed ops-bridge path. The workflow now builds the Nix OCI image,
|
||||
publishes to `gitea.coulomb.social/coulomb/inter-hub` using a registry bearer
|
||||
token from the repo `REGISTRY_TOKEN` Actions secret, deploys with Helm, and
|
||||
runs public smoke checks. Gitea Actions run `2913` completed successfully for
|
||||
commit `5663fab`.
|
||||
|
||||
**Load-control note (2026-06-14):** Added workflow `paths-ignore` for docs,
|
||||
workplans, `.custodian-brief.md`, `app.toml`, `.sops.yaml`, and
|
||||
`deploy/railiance/**` so State Hub consistency/doc-only commits do not consume a
|
||||
haskelseed build/deploy cycle.
|
||||
|
||||
**Bootstrap-gate deploy note (2026-06-14):** Hardened the deployment workflow
|
||||
smoke test so a production rollout only passes when `/api/v2/hubs` returns the
|
||||
expected unauthenticated `401` and OpenAPI exposes `/hubs`,
|
||||
`/hub-capability-manifests`, `/api-consumers`, and `/policy-scopes`. This
|
||||
directly protects the ops-hub bootstrap gate instead of only checking the
|
||||
landing page and generic widget auth gate.
|
||||
|
||||
**Authenticated inspection note (2026-06-14):** The stored local Tea token is
|
||||
stale for `https://gitea.coulomb.social`, but runner-side inspection succeeded.
|
||||
`make runner-status` in `railiance-forge` showed `act_runner` registered to
|
||||
`https://gitea.coulomb.social`, started under OpenRC, and carrying the expected
|
||||
`self-hosted`/`haskelseed` labels. The runner log shows task `19` for
|
||||
`coulomb/inter-hub` starting at `2026-06-14T19:59:19+02:00`, matching the
|
||||
`6455902` deploy trigger.
|
||||
|
||||
### R8 — Staged deployment and smoke test
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T08
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2b02ae5c-47b9-4f09-88f0-a4af7900b38f"
|
||||
```
|
||||
Follow the Railiance staged promotion lifecycle:
|
||||
|
||||
1. **Local verify** (done in R2 — container runs correctly)
|
||||
2. **Deploy to Railiance01:**
|
||||
```bash
|
||||
railiance deploy inter-hub --tag <sha>
|
||||
```
|
||||
3. **Smoke test:**
|
||||
```bash
|
||||
curl -s https://hub.coulomb.social/ | grep "Inter-Hub" # Landing page
|
||||
curl -s https://hub.coulomb.social/capabilities # Capabilities
|
||||
curl -H "Authorization: Bearer <key>" \
|
||||
https://hub.coulomb.social/api/v2/hubs # API (200)
|
||||
curl https://hub.coulomb.social/api/v2/hubs # Unauthenticated (200)
|
||||
```
|
||||
4. **Verify restart persistence:**
|
||||
```bash
|
||||
kubectl rollout restart deployment/inter-hub -n inter-hub
|
||||
kubectl rollout status deployment/inter-hub -n inter-hub
|
||||
# Then re-run smoke test
|
||||
```
|
||||
|
||||
**Recovery note (2026-06-14):** Production is deployed from image
|
||||
`gitea.coulomb.social/coulomb/inter-hub:5663fab`; Kubernetes reports the
|
||||
`inter-hub` deployment ready with one replica. Public smoke checks pass:
|
||||
`/` returns 200 and contains `inter-hub`, `/api/v2/openapi.json` returns 200,
|
||||
and unauthenticated `/api/v2/widgets` returns 401.
|
||||
|
||||
**DNS gate finding (2026-06-14):** The deployment workflow did publish and
|
||||
deploy `gitea.coulomb.social/coulomb/inter-hub:6455902`; Kubernetes reports the
|
||||
`inter-hub` Deployment ready on the COULOMBCORE K3s node
|
||||
`92.205.130.254`. An in-cluster probe to
|
||||
`http://inter-hub:8000/api/v2/hubs` returned the expected unauthenticated
|
||||
`401`, and forcing public TLS to `92.205.130.254` also returned `401`. The
|
||||
public DNS record for `hub.coulomb.social`, however, resolves to
|
||||
`92.205.62.239`, where `/api/v2/hubs` still returns `404` and OpenAPI lacks the
|
||||
bootstrap paths. The remaining production gate is therefore DNS cutover (or an
|
||||
intentional kubeconfig rotation to the cluster behind `92.205.62.239`), not a
|
||||
runner, build, registry, Helm, or image-content issue.
|
||||
|
||||
**Production gate completion note (2026-06-14):** DNS for
|
||||
`hub.coulomb.social` now resolves to `92.205.130.254`, cert-manager issued a
|
||||
Let's Encrypt certificate for the host, and the app deployment is serving image
|
||||
`gitea.coulomb.social/coulomb/inter-hub:6455902`. The final blockers were
|
||||
database ingress from `inter-hub` to `net-kingdom-pg` and the blank production
|
||||
schema. Added/applied the platform NetworkPolicy, initialized the `interhub`
|
||||
schema and framework type registries, granted privileges to the app role, and
|
||||
restarted the deployment. The ops-hub route probe now passes:
|
||||
`/api/v2/hubs` returns an unauthenticated response,
|
||||
`/api/v2/openapi.json` returns `200`, and OpenAPI exposes `/hubs`,
|
||||
`/hub-capability-manifests`, `/api-consumers`, and `/policy-scopes`.
|
||||
|
||||
### R9 — Document and register
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T09
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "4d1e55c7-8dbb-480f-b07b-6c5e39a04218"
|
||||
```
|
||||
- Write `deploy/railiance/RUNBOOK.md`: image build, migration procedure,
|
||||
secret rotation, rollback (`railiance rollback inter-hub`), log access
|
||||
(`kubectl logs -n inter-hub -l app=inter-hub --tail=100`)
|
||||
- Add progress event to state hub
|
||||
- Remove haskelseed socat/OpenRC production role note from quickstart -
|
||||
document it as the build machine only, not the production host
|
||||
|
||||
**Implementation note (2026-06-05):** `deploy/railiance/RUNBOOK.md` exists and
|
||||
documents architecture, image build/push, Helm deployment, logs, restart,
|
||||
rollback, secret rotation, and smoke checks. The deployment record remains
|
||||
incomplete until current `main` is running and the ops-hub bootstrap smoke test
|
||||
passes against production.
|
||||
|
||||
**Recovery note (2026-06-14):** Current `main` is running in production and the
|
||||
deployment evidence has been recorded here. Remaining documentation work is to
|
||||
capture the durable secret-management and railiance-apps handoff path once R5
|
||||
and R6 are completed.
|
||||
|
||||
**Completion note (2026-06-14):** Updated `deploy/railiance/RUNBOOK.md` for the
|
||||
current Gitea registry host, runner-based build/deploy path, SOPS secret handoff,
|
||||
current smoke checks, and haskelseed's build-runner-only role. Updated
|
||||
`docs/new-hub-quickstart.md` so haskelseed is no longer described as a
|
||||
production/shared database runtime.
|
||||
|
||||
### R10 - Externally verify ops-hub bootstrap gate follow-up
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T10
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Added after the helix-forge follow-up asking Inter-Hub to re-check the
|
||||
production bootstrap API gate from an external client before ops-hub proceeds.
|
||||
|
||||
**Verification note (2026-06-14):** External public probes from this workstation
|
||||
confirmed the deployed route existed, but this check treated the wrong status as
|
||||
success:
|
||||
|
||||
- `getent ahosts hub.coulomb.social` resolves to `92.205.130.254`.
|
||||
- `curl -s -o /tmp/interhub-hubs-body.txt -w "%{http_code}" \
|
||||
https://hub.coulomb.social/api/v2/hubs` returned `401`, which confirmed the
|
||||
route existed but not the correct public-discovery contract.
|
||||
- The unauthenticated response body was an API auth failure:
|
||||
`{"code":"invalid_api_key","error":"Unauthorized"}`.
|
||||
- `curl -s -o /tmp/interhub-openapi.json -w "%{http_code}" \
|
||||
https://hub.coulomb.social/api/v2/openapi.json` returned `200`.
|
||||
- Parsing `paths` from the downloaded OpenAPI document found all required
|
||||
bootstrap paths: `/hubs`, `/hub-capability-manifests`, `/api-consumers`, and
|
||||
`/policy-scopes`.
|
||||
|
||||
The deployed workflow smoke test also now captures `/api/v2/hubs` status
|
||||
without `curl -f`, verifies it equals `401`, and fails deployment if any of the
|
||||
four bootstrap OpenAPI paths are missing.
|
||||
|
||||
### R11 - Correct public hub discovery bootstrap contract
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0018-T11
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Follow-up correction after reviewing the ops-hub bootstrap hurdle: `GET
|
||||
/api/v2/hubs` is a discovery endpoint and should return `200` without an API
|
||||
key, not `401`. The authenticated boundary belongs on mutating bootstrap
|
||||
operations such as `POST /api/v2/hubs`, manifest writes/activation, API
|
||||
consumer creation, API key creation, and runtime widget/event submission.
|
||||
|
||||
**Implementation note (2026-06-14):** Updated the Hubs v2 controller so
|
||||
unauthenticated `GET /api/v2/hubs` returns the paginated hub list, while
|
||||
`POST /api/v2/hubs` still requires an API consumer. Updated generated OpenAPI
|
||||
contract helpers so public discovery operations explicitly set `security: []`
|
||||
instead of inheriting top-level Bearer auth. Updated the deployment workflow to
|
||||
require `/api/v2/hubs` to return `200` with a paginated `data` response, and
|
||||
updated the ops-hub bootstrap smoke helper to use unauthenticated hub discovery
|
||||
before authenticated mutations.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- `https://hub.coulomb.social/` returns the Landing page (200, no auth)
|
||||
- `/api/v2/hubs` returns 200 unauthenticated for discovery
|
||||
- All 12 IHF dashboards accessible after admin login
|
||||
- `kubectl rollout restart` followed by smoke test passes (K3s restart
|
||||
persistence confirmed)
|
||||
- Gitea Actions pipeline: push to `main` → image built → deployed → smoke
|
||||
test green within 15 minutes
|
||||
- No dependency on haskelseed being up for the app to *run* (only for builds)
|
||||
|
||||
## Open Questions / Pre-flight Checks
|
||||
|
||||
1. **K3s status**: ThreePhoenix HA cluster workstream is active but not complete.
|
||||
Confirm whether Railiance01 is a single-node cluster already accepting
|
||||
workloads or still being provisioned. Gate R3 is the go/no-go check.
|
||||
|
||||
2. **Container registry**: Is Gitea's built-in registry available on Railiance01,
|
||||
or is a separate registry service needed? If neither, add registry deployment
|
||||
to the scope.
|
||||
|
||||
3. **PostgreSQL HA status**: railiance-platform baseline workstream is active.
|
||||
Confirm whether the HA cluster (repmgr + pgpool) is operational before R4.
|
||||
|
||||
4. **Static asset bundling**: The Nix production binary may or may not include
|
||||
`static/app.css` (Tailwind output). Verify in R2 and adjust image build
|
||||
if needed.
|
||||
|
||||
5. **Anthropic API key**: Phase 5 AI-assisted distillation requires
|
||||
`IHP_ANTHROPIC_API_KEY`. Add to SOPS secrets if the feature is to be
|
||||
active on Railiance01.
|
||||
323
workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md
Normal file
323
workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
id: IHUB-WP-0019
|
||||
type: workplan
|
||||
title: "VSM Hub Bootstrap API Hardening"
|
||||
domain: inter_hub
|
||||
repo: inter-hub
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: inter_hub
|
||||
created: "2026-05-16"
|
||||
updated: "2026-05-19"
|
||||
planning_priority: high
|
||||
planning_order: 19
|
||||
related_repos:
|
||||
- helix-forge
|
||||
related_workplans:
|
||||
- HF-WP-0001
|
||||
state_hub_workstream_id: "ebde2b8b-8863-4008-9ebf-9bb0300d7375"
|
||||
---
|
||||
|
||||
# VSM Hub Bootstrap API Hardening
|
||||
|
||||
## Goal
|
||||
|
||||
Make Inter-Hub capable of bootstrapping VSM domain hubs, starting with
|
||||
`ops-hub`, through documented API calls or an explicit admin bootstrap command
|
||||
instead of manual UI-only setup or ad hoc database migrations.
|
||||
|
||||
This workplan is linked from `helix-forge` workplan `HF-WP-0001`, where
|
||||
`ops-hub` is being established as the first VSM Inter-Hub extension.
|
||||
|
||||
## Background
|
||||
|
||||
The current Inter-Hub implementation already supports the essential concepts:
|
||||
|
||||
- `Hub`
|
||||
- `HubCapabilityManifest`
|
||||
- type registries for widget types, event types, annotation categories, and
|
||||
policy scopes
|
||||
- `ApiConsumer`
|
||||
- `ApiKey`
|
||||
- widgets
|
||||
- v2 interaction events
|
||||
|
||||
However, the live public v2 API is currently read-heavy. The initial
|
||||
`ops-hub` bootstrap still requires authenticated UI flows or deployment-side
|
||||
migrations for hub creation, manifest creation/activation, API consumer/key
|
||||
creation, and widget seeding.
|
||||
|
||||
For HelixForge's VSM hub family, the desired repeatable bootstrap shape is:
|
||||
|
||||
```text
|
||||
Hub identity + VSM function + manifest vocabulary + API consumer + seed widgets + evidence events
|
||||
```
|
||||
|
||||
The same shape should later work for:
|
||||
|
||||
- `ops-hub` — Operations / System 1
|
||||
- `syn-hub` — Synchronization / System 2
|
||||
- `ctl-hub` — Internal Control / System 3
|
||||
- `aud-hub` — Audit / System 3*
|
||||
- `int-hub` — Intelligence and Adaptation / System 4
|
||||
- `pol-hub` — Policy and Identity / System 5
|
||||
- `env-hub` — Environment boundary
|
||||
|
||||
## Constraints
|
||||
|
||||
- Preserve the GAAF rule that hub-owned type names are declared through
|
||||
`HubCapabilityManifest` before use.
|
||||
- Do not weaken append-only invariants on `interaction_events`.
|
||||
- Do not expose raw static API keys after creation.
|
||||
- Keep UI flows working while adding API/admin bootstrap support.
|
||||
- Prefer explicit request schemas in OpenAPI instead of reusing response
|
||||
schemas as create contracts.
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Add scriptable hub and widget creation endpoints
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "72c5b7b2-632f-42ab-ac4d-eff123d8f143"
|
||||
```
|
||||
|
||||
Add documented v2 create endpoints for the records needed during a hub
|
||||
bootstrap:
|
||||
|
||||
- `POST /api/v2/hubs`
|
||||
- `POST /api/v2/widgets`
|
||||
|
||||
The endpoints should validate the same invariants as the UI controllers:
|
||||
|
||||
- hub slug/name/domain are required
|
||||
- `hubKind` accepts supported values only
|
||||
- widget type is registered
|
||||
- policy scope is registered when the registry is enforced
|
||||
- widget belongs to an existing hub
|
||||
|
||||
Done when: a script can create a hub row and seed widgets without direct DB
|
||||
access.
|
||||
|
||||
Implementation note (2026-05-16): added authenticated v2 `POST /api/v2/hubs`
|
||||
and `POST /api/v2/widgets`, with required-field validation, hub-kind/status
|
||||
validation, widget type and policy-scope registry checks, hub existence checks,
|
||||
initial widget-version snapshots, OpenAPI path entries, SDK helper methods, and
|
||||
focused Hspec helper coverage. The collection controllers now dispatch
|
||||
GET/POST by HTTP method so the create routes are reachable. Local
|
||||
`git diff --check` passed; `scripts/compile-check` could not run because this
|
||||
shell does not have `IHP_LIB`/the IHP dev environment loaded.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Add manifest and policy-scope API support
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "46a027d0-4831-40af-b8ae-e1f858cdaef7"
|
||||
```
|
||||
|
||||
Add documented API or admin-command support for:
|
||||
|
||||
- `HubCapabilityManifest` draft creation
|
||||
- manifest vocabulary update
|
||||
- manifest activation
|
||||
- listing policy scopes at `/api/v2/policy-scopes`
|
||||
|
||||
Done when: manifest activation can be executed without clicking through the UI
|
||||
and all four type registries are visible through v2 list endpoints.
|
||||
|
||||
Implementation note (2026-05-16): added authenticated
|
||||
`/api/v2/hub-capability-manifests` support for draft create, draft update, and
|
||||
activation, including the same manifest vocabulary conflict checks and
|
||||
idempotent registry upserts used by the UI flow. Added
|
||||
`/api/v2/policy-scopes`, OpenAPI path/schema entries, SDK helper methods, and
|
||||
focused Hspec helper coverage for manifest vocabulary parsing. Local
|
||||
`git diff --check` passed; `scripts/compile-check` could not run because this
|
||||
shell does not have `IHP_LIB`/the IHP dev environment loaded.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Add first-class VSM hub metadata
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T03
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "a90a0220-3d02-4b97-9fbf-a6bbbfa5019c"
|
||||
```
|
||||
|
||||
Decide where VSM hub-family metadata belongs and implement it consistently.
|
||||
|
||||
Candidate fields:
|
||||
|
||||
- `hub_family`
|
||||
- `vsm_function`
|
||||
- `vsm_system`
|
||||
|
||||
Candidate placement:
|
||||
|
||||
- new columns on `hubs`
|
||||
- manifest metadata JSON
|
||||
- a separate hub classification table
|
||||
|
||||
Done when: `ops-hub` can be represented as the VSM Operations / System 1 hub
|
||||
without hiding that classification inside prose.
|
||||
|
||||
Implementation note (2026-05-19): chose new nullable columns on `hubs`
|
||||
(`hub_family`, `vsm_function`, `vsm_system`) because the VSM role is hub
|
||||
identity/classification metadata, not manifest vocabulary. Added migration
|
||||
`1744588800-vsm-hub-metadata.sql`, schema constraints, v2 hub create/list/show
|
||||
JSON, hub registry JSON, compact registry/UI badges, OpenAPI request/response
|
||||
fields, SDK parameters, and validation tests. API validation now accepts either
|
||||
no VSM fields or `hubFamily=vsm` with non-empty `vsmFunction` and a supported
|
||||
`vsmSystem` (`1`, `2`, `3`, `3*`, `4`, `5`, or `environment`). `git diff
|
||||
--check` passed; `scripts/compile-check` is still blocked in this shell because
|
||||
`IHP_LIB` is not set.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Add API consumer and API key bootstrap support
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a50114d7-8719-45d5-9081-948df147d500"
|
||||
```
|
||||
|
||||
Add either documented v2 endpoints or an admin-only bootstrap command for:
|
||||
|
||||
- creating an `ApiConsumer`
|
||||
- binding it to an active manifest
|
||||
- creating a static API key
|
||||
- returning the full key exactly once
|
||||
|
||||
Done when: an operator can create an ops-hub API credential from a repeatable
|
||||
command while preserving the one-time secret display invariant.
|
||||
|
||||
Implementation note (2026-05-19): added authenticated v2
|
||||
`/api/v2/api-consumers` support for consumer create/list/show, including active
|
||||
manifest binding validation, positive rate-limit/quota validation, and
|
||||
`POST /api/v2/api-consumers/:id/api-keys` for one-time static key generation.
|
||||
Key hashes are stored; the raw `fullKey` is returned only in the key creation
|
||||
response. Added OpenAPI/SDK entries and focused Hspec helper coverage. Local
|
||||
`git diff --check` passed; `scripts/compile-check` could not run because this
|
||||
shell does not have `IHP_LIB`/the IHP dev environment loaded.
|
||||
|
||||
---
|
||||
|
||||
### T05 — Fix interaction-event create contract gaps
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1febfdb6-757b-420a-b4bd-709ce3cd1252"
|
||||
```
|
||||
|
||||
Fix the current v2 interaction event create behavior:
|
||||
|
||||
- decode active manifest `declaredEventTypes` instead of treating it as empty
|
||||
- persist submitted `metadata` if metadata is part of the create contract
|
||||
- dispatch webhooks using the submitted event type instead of hard-coded
|
||||
`"clicked"`
|
||||
- add tests around manifest-declared domain events
|
||||
|
||||
Done when: `ops-endpoint-verified` can be submitted with metadata and routed
|
||||
as an ops-owned event.
|
||||
|
||||
Implementation note (2026-05-16): v2 interaction-event creation now validates
|
||||
against active manifest-declared event types, persists submitted metadata from
|
||||
JSON request bodies, dispatches webhooks with the submitted event type, and has
|
||||
focused Hspec coverage for manifest-declared ops domain events. Local
|
||||
`git diff --check` passed; `scripts/compile-check` could not run because this
|
||||
shell does not have `IHP_LIB`/the IHP dev environment loaded.
|
||||
|
||||
---
|
||||
|
||||
### T06 — Update OpenAPI request schemas and hub quickstart docs
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "84c92e05-3e0f-490a-a48f-e2d9ddace764"
|
||||
```
|
||||
|
||||
Update OpenAPI and docs to match the real API:
|
||||
|
||||
- add distinct create request schemas for hubs, widgets, annotations,
|
||||
interaction events, manifests, consumers, and keys where applicable
|
||||
- remove or clearly mark aspirational quickstart calls until the endpoints
|
||||
exist
|
||||
- document the VSM hub bootstrap recipe using `ops-hub` as the example
|
||||
|
||||
Done when: a new hub implementer can follow docs without discovering missing
|
||||
API endpoints at runtime.
|
||||
|
||||
Implementation note (2026-05-19): updated the generated OpenAPI contract to
|
||||
use distinct request schemas for hub, manifest, API consumer/key, widget,
|
||||
interaction-event, and annotation writes. The spec now represents manifest
|
||||
activation and widget-pattern adoption as no-body actions, documents the
|
||||
one-time `ApiKeyCreatedResponse.fullKey`, adds missing hub registry and widget
|
||||
pattern response schemas, and fixes path parameter naming for `hubId`. Updated
|
||||
`docs/new-hub-quickstart.md` to show the supported `ops-hub` bootstrap path
|
||||
through `/api/v2`, including VSM metadata, manifest activation, consumer/key
|
||||
creation, widget seeding, and `metadata` event submission. Updated the
|
||||
functional contract endpoint list. `git diff --check` passed;
|
||||
`scripts/compile-check` remains blocked in this shell because `IHP_LIB` is not
|
||||
set.
|
||||
|
||||
---
|
||||
|
||||
### T07 — Add an ops-hub bootstrap smoke test
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T07
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "409b5f85-ec97-42e4-ad21-09e91b49639c"
|
||||
```
|
||||
|
||||
Add a smoke test or scripted check that exercises the full bootstrap path:
|
||||
|
||||
1. create `ops-hub`
|
||||
2. create and activate the manifest
|
||||
3. create API consumer/key
|
||||
4. seed a widget
|
||||
5. submit an `ops-endpoint-verified` event with metadata
|
||||
6. verify the event is listed by v2
|
||||
|
||||
Done when: the next VSM hub can be bootstrapped by adapting the same script
|
||||
and changing only vocabulary/configuration values.
|
||||
|
||||
Implementation note (2026-05-19): added executable
|
||||
`scripts/ops-hub-bootstrap-smoke.py`. The script uses only Python standard
|
||||
library modules and drives the documented v2 path end to end: creates or
|
||||
reuses `ops-hub`, creates/activates the ops manifest, creates a fresh runtime
|
||||
API consumer and one-time key, creates or reuses an ops widget, submits
|
||||
`ops-endpoint-verified` with metadata, and verifies the event through the v2
|
||||
list endpoint. It requires `IHUB_OPERATOR_KEY` and accepts `IHUB_BASE` plus hub
|
||||
identity overrides for adapting the same recipe to another VSM hub. Updated the
|
||||
quickstart to point to the script. `python3 -m py_compile` and `git diff
|
||||
--check` passed; the live smoke run itself requires a running Inter-Hub service
|
||||
and an operator API key.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
This workplan is complete when:
|
||||
|
||||
1. `ops-hub` can be created without direct DB access.
|
||||
2. Its manifest can be created and activated without manual UI-only steps.
|
||||
3. Its API consumer/key can be created by a repeatable operator path.
|
||||
4. Its widgets can be seeded by API or a documented admin command.
|
||||
5. Its first operational event can persist metadata and dispatch webhooks using
|
||||
the actual submitted event type.
|
||||
6. OpenAPI and docs accurately describe the supported bootstrap path.
|
||||
7. The same path is reusable for `syn-hub`, `ctl-hub`, `aud-hub`, `int-hub`,
|
||||
`pol-hub`, and `env-hub`.
|
||||
299
workplans/IHUB-WP-0020-personal-dashboard-framework.md
Normal file
299
workplans/IHUB-WP-0020-personal-dashboard-framework.md
Normal file
@@ -0,0 +1,299 @@
|
||||
---
|
||||
id: IHUB-WP-0020
|
||||
type: workplan
|
||||
title: "Personal Dashboard Framework"
|
||||
domain: inter_hub
|
||||
repo: inter-hub
|
||||
status: backlog
|
||||
owner: tegwick
|
||||
topic_slug: inter_hub
|
||||
created: "2026-05-03"
|
||||
updated: "2026-06-07"
|
||||
phase: 13
|
||||
state_hub_workstream_id: "72fc022b-0196-492a-aaba-3475f8768f06"
|
||||
---
|
||||
|
||||
# Personal Dashboard Framework
|
||||
|
||||
## Goal
|
||||
|
||||
Design and implement a personal dashboard framework that allows individual users to
|
||||
compose, configure, and persist a view of the inter-hub platform tailored to their
|
||||
role and focus. The dashboard is the post-login landing page and the primary daily
|
||||
driver surface for hub operators, governance reviewers, and AI orchestrators.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current post-login experience drops the user on a raw Hubs list. As inter-hub
|
||||
grows to 12+ phases of functionality, users need a curated, role-aware entry point
|
||||
that surfaces the signals that matter to them without requiring manual navigation.
|
||||
The dashboard is also the natural home for cross-cutting observability (recent
|
||||
decisions, open candidates, outcome signals) that cuts across the current
|
||||
controller-per-entity navigation.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Research: Dashboard frameworks and patterns for inspiration
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0020-T01
|
||||
status: todo
|
||||
priority: high
|
||||
state_hub_task_id: "6074f195-636b-4517-b6d1-eb3c57394a82"
|
||||
```
|
||||
|
||||
Survey existing dashboard systems to extract patterns that are re-implementable in
|
||||
Haskell / IHP / Tailwind under inter-hub's design constraints (server-rendered,
|
||||
type-safe, governed).
|
||||
|
||||
**Research targets:**
|
||||
|
||||
| System | What to extract |
|
||||
|---|---|
|
||||
| **Grafana** | Panel/grid layout model; datasource abstraction; variable-driven filtering |
|
||||
| **Kibana (dashboards)** | Saved-search panels; time-range awareness; role-based visibility |
|
||||
| **Retool / Appsmith** | Widget catalogue approach; drag-grid layout; data binding model |
|
||||
| **Linear (home view)** | Flat "My Work" aggregation across entities; priority surfacing |
|
||||
| **Notion (linked databases)** | Filter/sort persistence per user; view types (table, board, calendar) |
|
||||
| **Observable Framework** | Reactive cell model; markdown + code co-location |
|
||||
| **Metabase** | Question-as-unit; dashboard as ordered collection of questions |
|
||||
| **Streamlit** | Declarative layout (columns, expanders); pure functional rendering loop |
|
||||
|
||||
**Questions to answer per system:**
|
||||
1. How is a dashboard persisted? (JSON blob, relational rows, code-as-config?)
|
||||
2. How is a widget/panel parameterised? (datasource, filter, display options)
|
||||
3. How is layout described? (fixed grid, CSS grid, drag-and-drop, responsive breakpoints)
|
||||
4. How is per-user state separated from shared/team state?
|
||||
5. What is the update model? (full-page reload, WebSocket push, polling, partial HTMX swap)
|
||||
6. How are access controls expressed at panel level?
|
||||
|
||||
**IHP/Haskell-specific constraints to keep in mind:**
|
||||
- Server-rendered by default; AutoRefresh (WebSocket) available for live data
|
||||
- No client-side state management library; JS must be minimal
|
||||
- Type safety from DB schema → view layer is a first-class constraint
|
||||
- Tailwind CSS; no component library
|
||||
|
||||
**Exit criteria:** Research notes written in `docs/research/dashboard-frameworks.md`.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Product Requirements Specification (PRS)
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0020-T02
|
||||
status: todo
|
||||
priority: high
|
||||
depends_on: T01
|
||||
state_hub_task_id: "698304bc-b91a-42e2-a617-b3ddbf749174"
|
||||
```
|
||||
|
||||
Produce a formal PRS for the Personal Dashboard Framework based on T01 findings and
|
||||
inter-hub's design principles.
|
||||
|
||||
**Required sections:**
|
||||
|
||||
1. **Problem statement** — who uses the dashboard, what decisions they make with it,
|
||||
what pain the current flat-nav approach causes
|
||||
|
||||
2. **User personas**
|
||||
- Hub Operator: monitors activity within their hub; wants recent events, open candidates
|
||||
- Governance Reviewer: triages candidates, reviews decisions; needs queue and signal views
|
||||
- AI Orchestrator: monitors agent proposals, outcome correlations; needs performance panels
|
||||
- Platform Admin: watches system health, API usage, learning throughput
|
||||
|
||||
3. **Core requirements (MoSCoW)**
|
||||
- Must: per-user dashboard persisted in DB; selectable panels from a catalogue;
|
||||
layout preserved across sessions; role-aware default layout on first login
|
||||
- Should: panel-level filtering (by hub, by time range); live-update via AutoRefresh
|
||||
for signal panels; keyboard-navigable
|
||||
- Could: drag-and-drop layout editing; shared/team dashboards; dashboard templates
|
||||
- Won't (Phase 13): mobile-native layout; client-side data fetching; external datasources
|
||||
|
||||
4. **Non-functional requirements**
|
||||
- First paint < 500 ms (server-rendered, no JS data fetching)
|
||||
- Dashboard save/load < 100 ms
|
||||
- Each panel query < 200 ms (indexed, bounded result sets)
|
||||
- Zero JS frameworks; AutoRefresh WebSocket for live panels only
|
||||
|
||||
5. **Governance fit** — dashboard widgets are themselves `Widget` records in the IHF
|
||||
sense; `InteractionEvent`s recorded on dashboard interactions; annotations possible
|
||||
on any panel
|
||||
|
||||
**Exit criteria:** `docs/prs/dashboard-framework-prs.md` reviewed and accepted.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Functional Design Document (FDD)
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0020-T03
|
||||
status: todo
|
||||
priority: high
|
||||
depends_on: T02
|
||||
state_hub_task_id: "438e5771-a043-4f26-a1ce-994ed478a760"
|
||||
```
|
||||
|
||||
Translate the PRS into a concrete functional design covering schema, component model,
|
||||
rendering pipeline, and layout system. This is the authoritative reference for implementation.
|
||||
|
||||
**Required sections:**
|
||||
|
||||
#### 3.1 Data model
|
||||
|
||||
```sql
|
||||
-- A user's named dashboard
|
||||
CREATE TABLE dashboards (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- An instance of a panel type on a dashboard, with position
|
||||
CREATE TABLE dashboard_panels (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
|
||||
panel_type TEXT NOT NULL, -- FK to panel_type_registry
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
col INT NOT NULL DEFAULT 0,
|
||||
row INT NOT NULL DEFAULT 0,
|
||||
col_span INT NOT NULL DEFAULT 1,
|
||||
row_span INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Registry of available panel types
|
||||
CREATE TABLE panel_type_registry (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
description TEXT,
|
||||
default_config JSONB NOT NULL DEFAULT '{}',
|
||||
requires_hub BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
live_update BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
```
|
||||
|
||||
#### 3.2 Panel catalogue (Phase 13 scope)
|
||||
|
||||
| Panel type name | Label | Description | Live? |
|
||||
|---|---|---|---|
|
||||
| `recent-interactions` | Recent Activity | Latest interaction events across watched hubs | Yes |
|
||||
| `open-candidates` | Open Candidates | Requirement candidates awaiting triage | No |
|
||||
| `decision-queue` | Decision Queue | Decisions pending review | No |
|
||||
| `outcome-signals` | Outcome Signals | Recent outcome signal summary | Yes |
|
||||
| `hub-health` | Hub Health | Health snapshot per hub | No |
|
||||
| `agent-proposals` | Agent Proposals | Open AI agent proposals | No |
|
||||
| `learning-digest` | Learning Digest | Latest institutional knowledge entries | No |
|
||||
| `my-annotations` | My Annotations | Annotations by the current user | No |
|
||||
|
||||
#### 3.3 Layout system
|
||||
|
||||
12-column CSS grid; panels occupy `col_span` columns and `row_span` rows.
|
||||
Row height fixed at 240px. No drag-and-drop in Phase 13; layout edited via
|
||||
form fields (col, row, span). Responsive: collapse to single column below 768px.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [Recent Activity ×6] [Decision Queue ×3] [Hub ×3]│
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Open Candidates ×4] [Outcome Signals ×4] [My ×4] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 3.4 Rendering pipeline
|
||||
|
||||
```
|
||||
GET /Dashboard
|
||||
→ DashboardController#show
|
||||
→ fetch dashboard + panels (ordered by row, col)
|
||||
→ for each panel: dispatch to panel renderer (panelHtml panelType config)
|
||||
→ embed in DashboardView with CSS grid layout
|
||||
→ AutoRefresh wraps live panels only
|
||||
```
|
||||
|
||||
Each panel renderer is a Haskell function:
|
||||
```haskell
|
||||
type PanelRenderer = PanelConfig -> ModelContext -> IO Html
|
||||
renderPanel :: Text -> PanelConfig -> ModelContext -> IO Html
|
||||
renderPanel "recent-interactions" = renderRecentInteractions
|
||||
renderPanel "open-candidates" = renderOpenCandidates
|
||||
...
|
||||
```
|
||||
|
||||
#### 3.5 Edit flow
|
||||
|
||||
`GET /Dashboard/Edit` → grid with inline forms per panel (col/row/span inputs) +
|
||||
"Add Panel" dropdown from `panel_type_registry`. `POST /Dashboard` saves layout.
|
||||
No JavaScript needed for basic edit; optional HTMX for panel preview.
|
||||
|
||||
#### 3.6 Default dashboard on first login
|
||||
|
||||
`afterLoginRedirectPath` (in `SessionsControllerConfig`) redirects to `/Dashboard`.
|
||||
On first visit, `DashboardController` checks for an existing dashboard; if absent,
|
||||
creates a default one seeded from the user's role (determined by a `role` field
|
||||
to be added to `users`, or a simple heuristic based on existing data).
|
||||
|
||||
**Exit criteria:** FDD reviewed; schema migrations drafted; panel catalogue agreed.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Implementation workplan
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0020-T04
|
||||
status: todo
|
||||
priority: medium
|
||||
depends_on: T03
|
||||
state_hub_task_id: "970aa221-7e17-4500-8b37-9c98676280b1"
|
||||
```
|
||||
|
||||
Break T03's FDD into a detailed, sequenced task list suitable for execution as a new
|
||||
workplan (IHUB-WP-0021). Each task must have a clear entry/exit criterion and fit within
|
||||
the 8k token soft budget.
|
||||
|
||||
**Expected task structure of IHUB-WP-0021:**
|
||||
|
||||
```
|
||||
T01 Schema migration: dashboards, dashboard_panels, panel_type_registry
|
||||
T02 Seed: default panel types in panel_type_registry
|
||||
T03 DashboardController — show action (fetch + render)
|
||||
T04 Panel renderers — first 3 panels (recent-interactions, open-candidates, decision-queue)
|
||||
T05 DashboardView — CSS grid layout
|
||||
T06 Panel renderers — remaining 5 panels
|
||||
T07 Dashboard edit flow (layout form, add/remove panels)
|
||||
T08 Default dashboard seeding on first login
|
||||
T09 afterLoginRedirectPath → /Dashboard
|
||||
T10 AutoRefresh for live panels (recent-interactions, outcome-signals)
|
||||
T11 Role-aware default layout
|
||||
T12 Smoke tests
|
||||
```
|
||||
|
||||
**Exit criteria:** IHUB-WP-0021 workplan file committed; T01–T12 each have
|
||||
entry/exit criteria; ready for execution.
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria Summary
|
||||
|
||||
| Task | Deliverable | Status |
|
||||
|---|---|---|
|
||||
| T01 | `docs/research/dashboard-frameworks.md` | todo |
|
||||
| T02 | `docs/prs/dashboard-framework-prs.md` | todo |
|
||||
| T03 | `docs/fdd/dashboard-framework-fdd.md` | todo |
|
||||
| T04 | `workplans/IHUB-WP-0021-personal-dashboard-impl.md` | todo |
|
||||
|
||||
## Design Principles (binding throughout)
|
||||
|
||||
- **Server-first**: every panel renders in a single round-trip. No client-side data fetching.
|
||||
- **Type-safe config**: `PanelConfig` is a Haskell ADT, not an opaque JSON blob at runtime.
|
||||
- **IHF governed**: each rendered panel is a `Widget` with a `widgetEnvelope`; interactions
|
||||
are recorded; annotations can be attached.
|
||||
- **Tailwind only**: no external component library. Layout via CSS Grid with inline style for
|
||||
structural properties (lessons learned from sidebar nav).
|
||||
- **Minimal JS**: AutoRefresh WebSocket for live panels; vanilla JS for any UX enhancement.
|
||||
No framework, no bundler beyond the existing IHP asset pipeline.
|
||||
420
workplans/IHUB-WP-0022-ops-hub-evidence-intake.md
Normal file
420
workplans/IHUB-WP-0022-ops-hub-evidence-intake.md
Normal file
@@ -0,0 +1,420 @@
|
||||
---
|
||||
id: IHUB-WP-0022
|
||||
type: workplan
|
||||
title: "Ops Hub Evidence Intake for Activity Core"
|
||||
domain: inter_hub
|
||||
repo: inter-hub
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: inter_hub
|
||||
created: "2026-06-15"
|
||||
updated: "2026-06-15"
|
||||
planning_priority: high
|
||||
planning_order: 22
|
||||
related_repos:
|
||||
- activity-core
|
||||
- helix-forge
|
||||
related_workplans:
|
||||
- ACTIVITY-WP-0007
|
||||
- IHUB-WP-0019
|
||||
- HF-WP-0001
|
||||
state_hub_workstream_id: "bd086c41-287d-4a4e-8ac5-9ab270f14d72"
|
||||
---
|
||||
|
||||
# Ops Hub Evidence Intake for Activity Core
|
||||
|
||||
## Goal
|
||||
|
||||
Prepare the Inter-Hub `ops-hub` intake side for activity-core operational
|
||||
evidence events so `ACTIVITY-WP-0007` can move from State Hub fallback
|
||||
summaries to governed Inter-Hub submissions without ad hoc database access or
|
||||
ungoverned secrets.
|
||||
|
||||
This workplan comes from the activity-core suggestion:
|
||||
|
||||
- Message `18b4bf54-6fae-422b-ab29-8586bfc094e8`, created 2026-06-05:
|
||||
prepare the ops-hub intake side for `ops-service-observed`,
|
||||
`ops-endpoint-verified`, `ops-access-path-checked`,
|
||||
`ops-backup-verified`, and `ops-inventory-drift`.
|
||||
- Related closure-gate message `f3ec4a36-6abf-4550-be92-39f5709863de`,
|
||||
created 2026-06-07: activity-core can fall back to State Hub
|
||||
`ops_inventory_probe` summaries, but final activation waits on the Inter-Hub
|
||||
path or an explicit deferral decision.
|
||||
|
||||
Numbering note: `IHUB-WP-0021` is intentionally left available for the
|
||||
personal-dashboard implementation workplan already named by
|
||||
`IHUB-WP-0020-T04`.
|
||||
|
||||
## Background
|
||||
|
||||
Inter-Hub already has the generic bootstrap surface from `IHUB-WP-0019`:
|
||||
|
||||
- `POST /api/v2/hubs`
|
||||
- `POST /api/v2/hub-capability-manifests`
|
||||
- manifest activation with declared widget, event, annotation, and policy
|
||||
vocabulary
|
||||
- `POST /api/v2/api-consumers`
|
||||
- one-time API key creation
|
||||
- `POST /api/v2/widgets`
|
||||
- `POST /api/v2/interaction-events`
|
||||
|
||||
The quickstart in `docs/new-hub-quickstart.md` shows this path for `ops-hub`.
|
||||
However, the activity-core evidence stream needs a concrete, durable contract:
|
||||
which ops-hub widgets receive which event types, how `OPS_HUB_WIDGET_MAPPING`
|
||||
is shaped, where the runtime key is provisioned, and which payload fields
|
||||
activity-core may rely on.
|
||||
|
||||
Current production caveat as of 2026-06-15: the source fix for COUNT decoding
|
||||
is committed as `5101eb5`, but production image publication/deployment is
|
||||
still tracked in `ADHOC-2026-06-15`. Do not treat widget-create or
|
||||
hub-registry smoke checks as production-ready until that deployment gate is
|
||||
closed.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Define the `ops-hub` evidence vocabulary and target widget mapping for
|
||||
activity-core.
|
||||
- Document `OPS_HUB_WIDGET_MAPPING` and the expected event payload shape.
|
||||
- Provision or hand off the `OPS_HUB_KEY` secret through an approved
|
||||
operator-owned secret store outside Git.
|
||||
- Validate the State Hub fallback-first path through `ops_inventory_probe`.
|
||||
- Enable per-entity Inter-Hub submissions only after the widget/API-key path
|
||||
is live and smoke-tested.
|
||||
- Produce closure guidance for `ACTIVITY-WP-0007/T06`.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Building activity-core's evidence sink implementation.
|
||||
- Storing static API keys in Git, State Hub, workplans, logs, or chat.
|
||||
- Manual production DB seeding except under explicit operator approval.
|
||||
- Expanding ops-hub beyond the five activity-core evidence event types.
|
||||
- Changing the public/private authentication contract for existing v2 reads.
|
||||
|
||||
## Proposed Evidence Vocabulary
|
||||
|
||||
Activity-core has already declared the event contracts it wants to send:
|
||||
|
||||
| Event type | Suggested widget family | Purpose |
|
||||
|---|---|---|
|
||||
| `ops-service-observed` | service inventory | Record that a service exists and was observed. |
|
||||
| `ops-endpoint-verified` | endpoint inventory | Record endpoint reachability, auth challenge, or health verification. |
|
||||
| `ops-access-path-checked` | access path inventory | Record operator or service access path verification. |
|
||||
| `ops-backup-verified` | backup inventory | Record backup presence, recency, or restore-drill evidence. |
|
||||
| `ops-inventory-drift` | drift inventory | Record drift between expected and observed operations inventory. |
|
||||
|
||||
The first implementation should keep one stable widget per entity and evidence
|
||||
family where possible. If activity-core cannot know entity identity reliably,
|
||||
use one aggregate intake widget per family as a conservative first slice, then
|
||||
split into per-entity widgets after payload evidence proves stable.
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 - Audit current ops-hub bootstrap and activity-core contracts
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f9006504-e5f5-465f-9588-3f4279d12b84"
|
||||
```
|
||||
|
||||
Review the current Inter-Hub API, `docs/new-hub-quickstart.md`, the latest
|
||||
activity-core evidence sink contract, and State Hub messages related to
|
||||
`ACTIVITY-WP-0007`.
|
||||
|
||||
Answer:
|
||||
|
||||
- Which event types are already declared by activity-core?
|
||||
- Which widget types and policy scopes should ops-hub declare?
|
||||
- Does the live Inter-Hub deployment include the `5101eb5` COUNT decode fix?
|
||||
- Is `ops-hub` already present in the target environment, and is its manifest
|
||||
active?
|
||||
- Is there an existing API consumer/key that should be reused, rotated, or
|
||||
replaced?
|
||||
|
||||
Exit criteria: `docs/research/ops-hub-evidence-intake-current-state.md`
|
||||
exists with non-secret findings and open gates.
|
||||
|
||||
Implementation note (2026-06-15): completed
|
||||
`docs/research/ops-hub-evidence-intake-current-state.md`. The audit confirms
|
||||
that Inter-Hub has the required v2 widget and interaction-event primitives,
|
||||
activity-core has the five event definitions and a tested State Hub fallback
|
||||
sink, but the Inter-Hub sink is still deferred and no live
|
||||
`ops_inventory_probe` progress event exists in State Hub yet.
|
||||
|
||||
---
|
||||
|
||||
### T02 - Define the ops-hub evidence mapping contract
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T02
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: T01
|
||||
state_hub_task_id: "4f8a98b9-0d01-4333-b847-f83b8c85a5ab"
|
||||
```
|
||||
|
||||
Define the durable mapping that activity-core should receive as
|
||||
`OPS_HUB_WIDGET_MAPPING`.
|
||||
|
||||
The contract must specify:
|
||||
|
||||
- Map shape and version field.
|
||||
- Event type keys.
|
||||
- Widget identifiers or stable logical names for each event family.
|
||||
- Entity selector shape for per-service, per-endpoint, per-access-path, and
|
||||
per-backup submissions.
|
||||
- Fallback aggregate widgets for uncertain entity mapping.
|
||||
- Policy scope for operational evidence writes.
|
||||
- Rotation and compatibility expectations when widgets are renamed or split.
|
||||
|
||||
Exit criteria: `docs/contracts/ops-hub-activity-core-mapping.md` documents the
|
||||
mapping in copyable JSON examples without containing secrets.
|
||||
|
||||
Implementation note (2026-06-15): completed
|
||||
`docs/contracts/ops-hub-activity-core-mapping.md`. The contract defines a
|
||||
versioned non-secret `OPS_HUB_WIDGET_MAPPING` JSON shape, aggregate-first
|
||||
fallback widgets, per-entity selector rules, stable `widgetRef` values, and
|
||||
Secret-only handling for `OPS_HUB_KEY`.
|
||||
|
||||
---
|
||||
|
||||
### T03 - Prepare manifest vocabulary and seed widgets
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T03
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: T02
|
||||
state_hub_task_id: "94fc9806-781c-45f6-a43c-a6bce13da47b"
|
||||
```
|
||||
|
||||
Use the supported v2 bootstrap surface, or a documented operator-approved
|
||||
bootstrap script, to ensure `ops-hub` declares and activates the required
|
||||
vocabulary.
|
||||
|
||||
Required vocabulary:
|
||||
|
||||
- Widget type or types for service, endpoint, access path, backup, and drift
|
||||
evidence.
|
||||
- Event types listed in the activity-core suggestion.
|
||||
- Annotation category for operational risk or follow-up review.
|
||||
- Policy scope for ops evidence writes.
|
||||
|
||||
Seed the initial widgets named by the mapping contract.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- The active ops-hub manifest declares all required event types.
|
||||
- The widgets named in `OPS_HUB_WIDGET_MAPPING` exist.
|
||||
- `POST /api/v2/widgets` no longer fails with COUNT decode errors in the
|
||||
target environment.
|
||||
- Non-secret widget IDs or logical names are recorded in the mapping contract.
|
||||
|
||||
Current wait reason (2026-06-15): manifest/widget activation needs a target
|
||||
environment with the `5101eb5` COUNT decode fix live and an authenticated
|
||||
operator/runtime key path. The required vocabulary is documented, but no live
|
||||
manifest or widget seed was performed in this implementation slice.
|
||||
|
||||
---
|
||||
|
||||
### T04 - Provision the runtime API key outside Git
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T04
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: T03
|
||||
state_hub_task_id: "267db6a7-67d2-48af-b3e8-7588f8684957"
|
||||
```
|
||||
|
||||
Create or confirm the ops-hub runtime API consumer and static key, then store
|
||||
the full key only in the approved operator-owned secret store.
|
||||
|
||||
Rules:
|
||||
|
||||
- Never print, commit, or paste the full static key into Git, State Hub, or
|
||||
chat.
|
||||
- If a key already exists in a temporary local file, move it into the approved
|
||||
secret path and remove the temp file after verification.
|
||||
- Prefer OpenBao path `platform/operators/ops-hub/runtime`, field
|
||||
`OPS_HUB_KEY`, unless the operator chooses a different approved path.
|
||||
- Record only non-secret evidence: consumer id, key creation time, storage
|
||||
path, and verification result.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Activity-core has an approved way to receive `OPS_HUB_KEY`.
|
||||
- `POST /api/v2/token` succeeds for the runtime key or the selected auth path.
|
||||
- A protected ops-hub read/write smoke can run without exposing the key.
|
||||
|
||||
Current wait reason: storing the runtime key in OpenBao requires an attended
|
||||
root/sudo-capable token handoff or operator UI action.
|
||||
|
||||
---
|
||||
|
||||
### T05 - Document event payload shape and validation rules
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T05
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: T02
|
||||
state_hub_task_id: "4eb04a83-8eea-4cab-861c-a39f312a5bb9"
|
||||
```
|
||||
|
||||
Document the payload shape activity-core should send to
|
||||
`POST /api/v2/interaction-events`.
|
||||
|
||||
The document must cover:
|
||||
|
||||
- Required Inter-Hub fields: `widgetId`, `eventType`, `viewContext`, and
|
||||
`metadata`.
|
||||
- Per-event metadata fields activity-core should include.
|
||||
- Entity identity fields and how to handle unknown values.
|
||||
- Timestamp semantics: observed time versus submitted time.
|
||||
- Severity/result vocabulary, if used.
|
||||
- Idempotency or duplicate tolerance expectations.
|
||||
- Privacy and secret redaction rules.
|
||||
- Expected API errors and recovery behavior.
|
||||
|
||||
Exit criteria: `docs/contracts/ops-hub-activity-core-event-payloads.md`
|
||||
exists with one example payload for each of the five event types.
|
||||
|
||||
Implementation note (2026-06-15): completed
|
||||
`docs/contracts/ops-hub-activity-core-event-payloads.md`. It documents the
|
||||
Inter-Hub request envelope, shared validation rules, idempotency expectations,
|
||||
forbidden payload material, expected API errors, and one example for each
|
||||
activity-core event type.
|
||||
|
||||
---
|
||||
|
||||
### T06 - Validate fallback-first intake
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T06
|
||||
status: wait
|
||||
priority: medium
|
||||
depends_on: T01
|
||||
state_hub_task_id: "38b54991-bed2-4f9d-bede-bea35821b1ef"
|
||||
```
|
||||
|
||||
Before enabling per-entity Inter-Hub submission, accept and review the State
|
||||
Hub fallback evidence path that activity-core already supports.
|
||||
|
||||
Validation path:
|
||||
|
||||
- Trigger or review an `ops_inventory_probe` fallback summary.
|
||||
- Confirm it contains enough non-secret evidence to preserve operating
|
||||
continuity while Inter-Hub submission is gated.
|
||||
- Record which evidence cannot be represented well in fallback summaries and
|
||||
should move to Inter-Hub first.
|
||||
- Decide whether `ACTIVITY-WP-0007/T06` may close with Inter-Hub submission
|
||||
explicitly deferred, or whether live Inter-Hub submission is a hard closure
|
||||
gate.
|
||||
|
||||
Exit criteria: `docs/evidence/ops-hub-activity-core-fallback-validation.md`
|
||||
records fallback evidence, gaps, and the closure recommendation.
|
||||
|
||||
Implementation note (2026-06-15): created
|
||||
`docs/evidence/ops-hub-activity-core-fallback-validation.md`. Activity-core's
|
||||
fallback sink is implemented and locally tested, but a direct State Hub query
|
||||
for `event_type=ops_inventory_probe` returned no live events. This task remains
|
||||
waiting on a disabled/manual activity-core probe or other live fallback
|
||||
evidence before it can close.
|
||||
|
||||
---
|
||||
|
||||
### T07 - Run end-to-end Inter-Hub submission smoke
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T07
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: T03,T04,T05
|
||||
state_hub_task_id: "23baee9b-d710-42c8-9a19-f936bd237444"
|
||||
```
|
||||
|
||||
Run a controlled submission from activity-core or a fixture that uses the same
|
||||
environment variables and mapping shape:
|
||||
|
||||
- `INTER_HUB_URL`
|
||||
- `OPS_HUB_KEY`
|
||||
- `OPS_HUB_WIDGET_MAPPING`
|
||||
|
||||
Smoke checks:
|
||||
|
||||
- One event per evidence type is accepted by
|
||||
`POST /api/v2/interaction-events`.
|
||||
- Event type enforcement rejects an undeclared event type.
|
||||
- Metadata is persisted and returned by the relevant list/show endpoint.
|
||||
- API rate-limit and hub-registry reads do not hit COUNT decode failures.
|
||||
- Failure mode is clean when config is absent, matching activity-core's current
|
||||
gated behavior.
|
||||
|
||||
Exit criteria: non-secret smoke evidence is recorded and activity-core can
|
||||
enable the Inter-Hub sink in its controlled environment.
|
||||
|
||||
Current wait reason (2026-06-15): depends on live manifest/widgets from T03,
|
||||
runtime key provisioning from T04, and activity-core implementing actual
|
||||
Inter-Hub submission beyond its current deferred sink.
|
||||
|
||||
---
|
||||
|
||||
### T08 - Coordinate ACTIVITY-WP-0007 closure handoff
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0022-T08
|
||||
status: wait
|
||||
priority: medium
|
||||
depends_on: T06,T07
|
||||
state_hub_task_id: "4a7ed0ed-552e-42d3-a90f-1efd52b8851e"
|
||||
```
|
||||
|
||||
Send a closure decision or handoff back to activity-core.
|
||||
|
||||
The handoff must state:
|
||||
|
||||
- Whether `ACTIVITY-WP-0007/T06` can close on fallback evidence with explicit
|
||||
Inter-Hub deferral.
|
||||
- Or whether live Inter-Hub submission is now ready and should be required
|
||||
before closure.
|
||||
- Which config values activity-core needs, naming only secret references and
|
||||
never secret values.
|
||||
- Which widgets/event types are active.
|
||||
- Which smoke evidence was collected.
|
||||
|
||||
Exit criteria: activity-core has enough non-secret evidence and configuration
|
||||
shape to close or unblock `ACTIVITY-WP-0007/T06`.
|
||||
|
||||
Current wait reason (2026-06-15): closure handoff depends on either a live
|
||||
State Hub fallback event plus an explicit Inter-Hub deferral decision, or a
|
||||
successful Inter-Hub submission smoke.
|
||||
|
||||
## Exit Criteria Summary
|
||||
|
||||
| Task | Deliverable | Status |
|
||||
|---|---|---|
|
||||
| T01 | `docs/research/ops-hub-evidence-intake-current-state.md` | done |
|
||||
| T02 | `docs/contracts/ops-hub-activity-core-mapping.md` | done |
|
||||
| T03 | Active ops-hub manifest and seed widgets | wait |
|
||||
| T04 | `OPS_HUB_KEY` stored outside Git and smokeable | wait |
|
||||
| T05 | `docs/contracts/ops-hub-activity-core-event-payloads.md` | done |
|
||||
| T06 | `docs/evidence/ops-hub-activity-core-fallback-validation.md` | wait |
|
||||
| T07 | End-to-end Inter-Hub submission smoke evidence | wait |
|
||||
| T08 | activity-core closure handoff | wait |
|
||||
|
||||
## Binding Principles
|
||||
|
||||
- Governed vocabulary first: every event type must come from an active
|
||||
manifest before activity-core sends it.
|
||||
- Secret custody stays out of Git: workplans may name paths and fields, never
|
||||
key values.
|
||||
- Fallback before activation: State Hub fallback evidence remains the safety
|
||||
path until the Inter-Hub widget/API-key path is verified.
|
||||
- Aggregate first, split later: use aggregate widgets when entity identity is
|
||||
ambiguous, then move to per-entity widgets only when stable.
|
||||
- No manual DB access by default: use the documented v2 API unless the operator
|
||||
explicitly approves a fallback.
|
||||
Reference in New Issue
Block a user