generated from coulomb/repo-seed
Compare commits
334 Commits
v0.2.0-alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b90320e4e7 | |||
| 9582bf5277 | |||
| f27e1551c9 | |||
| e93145966f | |||
| e558d3deba | |||
| dbab7ac1d7 | |||
| be419ddb80 | |||
| ee7948ba5f | |||
| e8e8187946 | |||
| d6b655a5cf | |||
| a8292b639b | |||
| 3d19ba6929 | |||
| 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
|
||||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=inter-hub` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
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("infotech")` shows **no workstreams**.
|
||||||
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
|
**Step 1 — Read, don't write**
|
||||||
|
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||||
|
- `~/the-custodian/canon/projects/infotech/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/IHUB-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 infotech 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:** infotech
|
||||||
|
**Repo slug:** inter-hub
|
||||||
|
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||||
|
MCP server name in `~/.claude.json`: `dev-hub`
|
||||||
|
|
||||||
|
**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("infotech")
|
||||||
|
```
|
||||||
|
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
|
||||||
|
`wait`/`todo`/`progress` tasks.
|
||||||
|
|
||||||
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
1. **Active workstreams** for `infotech` — 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)
|
||||||
|
```
|
||||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
File location: `workplans/IHUB-WP-NNNN-<slug>.md`
|
||||||
|
ID prefix: `IHUB-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-IHUB-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.
|
||||||
|
|
||||||
|
Task blocks use this shape:
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||||
|
blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
<!-- 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}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||||
# Custodian Brief — inter-hub
|
# Custodian Brief — inter-hub
|
||||||
|
|
||||||
**Domain:** inter_hub
|
**Domain:** infotech
|
||||||
**Last synced:** 2026-04-29 11:40 UTC
|
**Last synced:** 2026-06-23 19:49 UTC
|
||||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
## Current Goal
|
## Current Goal
|
||||||
@@ -11,41 +11,38 @@ IHF Phase 1 complete — Phase 2 ready to start
|
|||||||
|
|
||||||
## Active Workstreams
|
## Active Workstreams
|
||||||
|
|
||||||
### Autonomous Error-Fix Loop: Reach Clean Build
|
### Personal Dashboard Implementation
|
||||||
Progress: 0/5 done | workstream_id: `4636eb67-f7fb-409c-8d13-7fb461ef5db2`
|
Progress: 0/12 done | workstream_id: `79f72176-fb3f-4d59-9678-d42f5ff1e679`
|
||||||
|
|
||||||
**Open tasks:**
|
**Open tasks:**
|
||||||
- · E1 — Start compile-check and capture initial error set `0ddc0559`
|
- · T01 - Add personal dashboard schema `bb7366a3`
|
||||||
- · E2 — Fix Layer 2 errors (Application/Helper/*.hs) `2cd3dbb3`
|
- · T02 - Seed panel types and framework panel vocabulary `d298eab2`
|
||||||
- · E3 — Fix Layer 3 errors (Web/Controller/*.hs and Web/View/**/*.hs) `99c4345c`
|
- · T03 - Add controller skeleton, routes, and default dashboard helper `8a171c71`
|
||||||
- · E4 — Fix Layer 4 errors (Web/FrontController.hs, Web/Routes.hs) `c5dda487`
|
- · T04 - Implement first three panel view models/renderers `012dcd2a`
|
||||||
- · E5 — Commit clean build and close WP-0014/A1 `e20d48ea`
|
- · T05 - Implement dashboard show view and responsive grid `b4c4de39`
|
||||||
|
- · T06 - Implement remaining first-slice panel view models/renderers `8d0bd046`
|
||||||
|
- · T07 - Implement edit flow `51a72b56`
|
||||||
|
- … and 5 more open tasks
|
||||||
|
|
||||||
### Local Deployment — Intro and Tutorial Web UI
|
### Ops Hub Evidence Intake for Activity Core
|
||||||
Progress: 0/7 done | workstream_id: `946d50b8-441c-4c0a-b1a0-2a4fb3340d16`
|
Progress: 5/8 done | workstream_id: `bd086c41-287d-4a4e-8ac5-9ab270f14d72`
|
||||||
|
|
||||||
**Open tasks:**
|
**Open tasks:**
|
||||||
- · B1 — Create StaticPages controller `e08a4e99`
|
- ! T03 - Prepare manifest vocabulary and seed widgets `94fc9806`
|
||||||
- · B2 — Landing page view `2a2d4572`
|
- ! T04 - Provision the runtime API key outside Git `267db6a7`
|
||||||
- · B3 — Capabilities page view `112311bd`
|
- ! T07 - Run end-to-end Inter-Hub submission smoke `23baee9b`
|
||||||
- · 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`
|
|
||||||
|
|
||||||
### Pre-flight: Close Deployment Gaps
|
### Ad hoc Inter-Hub production fixes
|
||||||
Progress: 2/6 done | workstream_id: `532761e7-7c97-42e6-a5ea-59a972a80230`
|
Progress: 0/1 done | workstream_id: `9e7a50b4-da7f-4df9-9154-7b89a071f520`
|
||||||
|
|
||||||
**Open tasks:**
|
**Open tasks:**
|
||||||
- ► A2 — Fix compilation errors `40787dd7`
|
- ! Fix COUNT decode failures in v2 bootstrap endpoints `cceee9f1`
|
||||||
- · A3 — Enable Tailwind CSS build pipeline `45389d55`
|
*(wait: Protected production acceptance requires an approved operator/runtime key handoff or operator-provided deploy/smoke evidence; no approved key is available in this session and unauthenticated registry manifest checks return 401.)*
|
||||||
- · A4 — Admin user seed migration `62a407f9`
|
|
||||||
- · A5 — Smoke test `326397bc`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
## MCP Orientation (when available)
|
## MCP Orientation (when available)
|
||||||
|
|
||||||
If the state-hub MCP server is reachable, call:
|
If the state-hub MCP server is reachable, call:
|
||||||
`get_domain_summary("inter_hub")`
|
`get_domain_summary("infotech")`
|
||||||
This provides richer cross-domain context.
|
This provides richer cross-domain context.
|
||||||
If the MCP call fails, use this file as your orientation source.
|
If the MCP call fails, use this file as your orientation source.
|
||||||
|
|||||||
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
|
||||||
27
.repo-classification.yaml
Normal file
27
.repo-classification.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Repo classification (Repo Classification Standard v1.0).
|
||||||
|
|
||||||
|
repo_classification:
|
||||||
|
standard: Repo Classification Standard
|
||||||
|
version: '1.0'
|
||||||
|
classified_at: '2026-06-22'
|
||||||
|
classified_by: human
|
||||||
|
category: research
|
||||||
|
domain: infotech
|
||||||
|
secondary_domains:
|
||||||
|
- agents
|
||||||
|
capability_tags:
|
||||||
|
- governance
|
||||||
|
- observability
|
||||||
|
- platform
|
||||||
|
- coordination
|
||||||
|
- orchestration
|
||||||
|
business_stake:
|
||||||
|
- technology
|
||||||
|
- intelligence
|
||||||
|
- operations
|
||||||
|
business_mechanics:
|
||||||
|
- control
|
||||||
|
- coordination
|
||||||
|
- adaptation
|
||||||
|
notes: Specification + reference implementation of the Interaction Hub Framework (IHF).
|
||||||
|
Core output is the governed framework/substrate, so classified research.
|
||||||
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
|
||||||
219
AGENTS.md
Normal file
219
AGENTS.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 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:** infotech
|
||||||
|
**Repo slug:** inter-hub
|
||||||
|
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||||
|
**Workplan prefix:** `IHUB-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": "progress"}'
|
||||||
|
# values: wait | todo | progress | done | cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 human-needed 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=inter-hub` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
|
|
||||||
|
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||||
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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: infotech
|
||||||
|
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: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
` ` `
|
||||||
|
|
||||||
|
Task description text.
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
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
|
checkRateLimitAndLog consumer endpoint method = do
|
||||||
-- Check rate limit: requests in last 60 seconds
|
-- Check rate limit: requests in last 60 seconds
|
||||||
rows1 <- sqlQuery
|
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'"
|
\WHERE api_consumer_id = ? AND requested_at >= NOW() - INTERVAL '60 seconds'"
|
||||||
(Only consumer.id)
|
(Only consumer.id)
|
||||||
let reqCount = case rows1 of
|
let reqCount = case rows1 of
|
||||||
@@ -43,7 +43,7 @@ checkRateLimitAndLog consumer endpoint method = do
|
|||||||
|
|
||||||
-- Check daily quota
|
-- Check daily quota
|
||||||
rows2 <- sqlQuery
|
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'"
|
\WHERE api_consumer_id = ? AND requested_at >= ? - INTERVAL '1 day'"
|
||||||
(consumer.id, consumer.quotaResetsAt)
|
(consumer.id, consumer.quotaResetsAt)
|
||||||
let quotaUsed = case rows2 of
|
let quotaUsed = case rows2 of
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ validateWidgetType ::
|
|||||||
Text -> IO (Either Text ())
|
Text -> IO (Either Text ())
|
||||||
validateWidgetType name = do
|
validateWidgetType name = do
|
||||||
rows <- sqlQuery
|
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)
|
(Only name)
|
||||||
case rows of
|
case rows of
|
||||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||||
@@ -24,7 +24,7 @@ validateEventType ::
|
|||||||
Text -> IO (Either Text ())
|
Text -> IO (Either Text ())
|
||||||
validateEventType name = do
|
validateEventType name = do
|
||||||
rows <- sqlQuery
|
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)
|
(Only name)
|
||||||
case rows of
|
case rows of
|
||||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||||
@@ -36,7 +36,7 @@ validateAnnotationCategory ::
|
|||||||
Text -> IO (Either Text ())
|
Text -> IO (Either Text ())
|
||||||
validateAnnotationCategory name = do
|
validateAnnotationCategory name = do
|
||||||
rows <- sqlQuery
|
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)
|
(Only name)
|
||||||
case rows of
|
case rows of
|
||||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||||
@@ -48,7 +48,7 @@ validatePolicyScope ::
|
|||||||
Text -> IO (Either Text ())
|
Text -> IO (Either Text ())
|
||||||
validatePolicyScope name = do
|
validatePolicyScope name = do
|
||||||
rows <- sqlQuery
|
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)
|
(Only name)
|
||||||
case rows of
|
case rows of
|
||||||
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
[Only (n :: Int)] | n > 0 -> pure (Right ())
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
-- Seed default admin user for initial local deployment.
|
-- Seed default admin user for initial local deployment.
|
||||||
-- Password: admin1234!
|
-- 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.
|
-- IMPORTANT: Change this password immediately after first login via the profile settings.
|
||||||
-- Workplan: IHUB-WP-0014 (A4 — admin user seeding)
|
-- 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 (
|
VALUES (
|
||||||
uuid_generate_v4(),
|
uuid_generate_v4(),
|
||||||
'admin@inter-hub.local',
|
'admin@inter-hub.local',
|
||||||
'$2b$10$c3imjL8nLkR1TSbBifvR3eFzlCUurGPXsN7K5trDjmZL6Af3zLqH.',
|
'sha256|17|hyVUQpp0hhegCg2oM0lUHQ==|jSwCi+tJUlKCW6sT6nn23/r71fd0GSiVOo48JSrXyWc=',
|
||||||
'Admin',
|
'Admin',
|
||||||
0,
|
0,
|
||||||
now()
|
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,
|
domain TEXT NOT NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
api_key TEXT,
|
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
|
-- 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)
|
CREATE UNIQUE INDEX hubs_one_framework_idx ON hubs (hub_kind)
|
||||||
WHERE hub_kind = 'framework';
|
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
|
-- T03 — Type registries
|
||||||
|
|
||||||
CREATE TABLE widget_type_registry (
|
CREATE TABLE widget_type_registry (
|
||||||
|
|||||||
190
CLAUDE.md
190
CLAUDE.md
@@ -1,180 +1,12 @@
|
|||||||
# 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.
|
@SCOPE.md
|
||||||
|
@.claude/rules/repo-identity.md
|
||||||
## Project Overview
|
@.claude/rules/session-protocol.md
|
||||||
|
@.claude/rules/first-session.md
|
||||||
**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.
|
@.claude/rules/workplan-convention.md
|
||||||
|
@.claude/rules/stack-and-commands.md
|
||||||
**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.
|
@.claude/rules/architecture.md
|
||||||
|
@.claude/rules/repo-boundary.md
|
||||||
For situational context, read `SCOPE.md`. For architecture depth, read `specs/InteractionHubFrameworkSpecification_v0.1.md`.
|
@.claude/rules/credential-routing.md
|
||||||
|
@.claude/rules/agents.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
|
|
||||||
|
|||||||
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/turbolinksInstantClick.js
|
||||||
JS_FILES += ${IHP}/static/vendor/turbolinksMorphdom.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
|
## 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)
|
- 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)
|
- 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)
|
- 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
|
- 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
|
- `the-custodian` — provides state-hub (decision records, workstreams) that IHF governance ledger will integrate with
|
||||||
- `ops-bridge` — tunnel connectivity for remote hub surfaces
|
- `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 Test.Hspec
|
||||||
import IHP.Prelude
|
import IHP.Prelude
|
||||||
import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary
|
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 :: IO ()
|
||||||
main = hspec do
|
main = hspec do
|
||||||
@@ -10,4 +25,110 @@ main = hspec do
|
|||||||
it "should pass" do
|
it "should pass" do
|
||||||
1 + 1 `shouldBe` (2 :: Int)
|
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
|
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,25 +10,15 @@ import Web.Controller.Api.V2.Auth
|
|||||||
, respondWithStatus )
|
, respondWithStatus )
|
||||||
import Application.Helper.TypeRegistry (validateAnnotationCategory)
|
import Application.Helper.TypeRegistry (validateAnnotationCategory)
|
||||||
import qualified Data.UUID as UUID
|
import qualified Data.UUID as UUID
|
||||||
|
import Network.Wai (requestMethod)
|
||||||
|
|
||||||
instance Controller ApiV2AnnotationsController where
|
instance Controller ApiV2AnnotationsController where
|
||||||
|
|
||||||
action ApiV2IndexAnnotationsAction = do
|
action ApiV2IndexAnnotationsAction = do
|
||||||
_consumer <- requireApiConsumer
|
case requestMethod ?request of
|
||||||
(page, perPage) <- getPageParams
|
"GET" -> listAnnotations
|
||||||
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
|
"POST" -> createApiAnnotation
|
||||||
mCategory = paramOrNothing @Text "category"
|
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||||
let off = (page - 1) * perPage
|
|
||||||
let baseQ = query @Annotation |> orderByDesc #createdAt
|
|
||||||
let q1 = case mWidgetId of
|
|
||||||
Just wId -> baseQ |> filterWhere (#widgetId, wId)
|
|
||||||
Nothing -> baseQ
|
|
||||||
let q2 = case mCategory of
|
|
||||||
Just cat -> q1 |> filterWhere (#category, cat)
|
|
||||||
Nothing -> q1
|
|
||||||
total <- q2 |> fetchCount
|
|
||||||
anns <- q2 |> limit perPage |> offset off |> fetch
|
|
||||||
renderJson $ paginatedResponse (map annotationToJson anns) page perPage total
|
|
||||||
|
|
||||||
action ApiV2ShowAnnotationAction { annotationId } = do
|
action ApiV2ShowAnnotationAction { annotationId } = do
|
||||||
_consumer <- requireApiConsumer
|
_consumer <- requireApiConsumer
|
||||||
@@ -36,54 +26,75 @@ instance Controller ApiV2AnnotationsController where
|
|||||||
renderJson (annotationToJson ann)
|
renderJson (annotationToJson ann)
|
||||||
|
|
||||||
-- POST /api/v2/annotations
|
-- POST /api/v2/annotations
|
||||||
action ApiV2CreateAnnotationAction = do
|
action ApiV2CreateAnnotationAction = createApiAnnotation
|
||||||
_consumer <- requireApiConsumer
|
|
||||||
let widgetIdText = paramOrNothing @Text "widgetId"
|
|
||||||
category = paramOrNothing @Text "category"
|
|
||||||
body = paramOrNothing @Text "body"
|
|
||||||
|
|
||||||
let missing = catMaybes
|
listAnnotations :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||||
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
listAnnotations = do
|
||||||
, if isNothing category then Just "category" else Nothing
|
_consumer <- requireApiConsumer
|
||||||
, if isNothing body then Just "body" else Nothing
|
(page, perPage) <- getPageParams
|
||||||
]
|
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
|
||||||
unless (null missing) do
|
mCategory = paramOrNothing @Text "category"
|
||||||
respondWithStatus 422 $ object
|
let off = (page - 1) * perPage
|
||||||
[ "error" .= ("Missing required fields" :: Text)
|
let baseQ = query @Annotation |> orderByDesc #createdAt
|
||||||
, "missing" .= missing
|
let q1 = case mWidgetId of
|
||||||
]
|
Just wId -> baseQ |> filterWhere (#widgetId, wId)
|
||||||
|
Nothing -> baseQ
|
||||||
|
let q2 = case mCategory of
|
||||||
|
Just cat -> q1 |> filterWhere (#category, cat)
|
||||||
|
Nothing -> q1
|
||||||
|
total <- q2 |> fetchCount
|
||||||
|
anns <- q2 |> limit perPage |> offset off |> fetch
|
||||||
|
renderJson $ paginatedResponse (map annotationToJson anns) page perPage total
|
||||||
|
|
||||||
let Just wIdText = widgetIdText
|
createApiAnnotation :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||||
Just cat = category
|
createApiAnnotation = do
|
||||||
Just bodyTxt = body
|
_consumer <- requireApiConsumer
|
||||||
|
let widgetIdText = paramOrNothing @Text "widgetId"
|
||||||
|
category = paramOrNothing @Text "category"
|
||||||
|
body = paramOrNothing @Text "body"
|
||||||
|
|
||||||
catResult <- liftIO $ validateAnnotationCategory cat
|
let missing = catMaybes
|
||||||
case catResult of
|
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
||||||
Left _ -> respondWithStatus 422 $ object
|
, if isNothing category then Just "category" else Nothing
|
||||||
[ "error" .= ("Unregistered annotation category" :: Text)
|
, if isNothing body then Just "body" else Nothing
|
||||||
, "code" .= ("unregistered_category" :: Text)
|
]
|
||||||
, "value" .= cat
|
unless (null missing) do
|
||||||
, "registry" .= ("/api/v2/annotation-categories" :: Text)
|
respondWithStatus 422 $ object
|
||||||
]
|
[ "error" .= ("Missing required fields" :: Text)
|
||||||
Right () -> pure ()
|
, "missing" .= missing
|
||||||
|
]
|
||||||
|
|
||||||
case UUID.fromText wIdText of
|
let Just wIdText = widgetIdText
|
||||||
Nothing -> respondWithStatus 422 $ object
|
Just cat = category
|
||||||
["error" .= ("widgetId must be a valid UUID" :: Text)]
|
Just bodyTxt = body
|
||||||
Just rawId -> do
|
|
||||||
let wId = Id rawId :: Id Widget
|
catResult <- liftIO $ validateAnnotationCategory cat
|
||||||
mWidget <- fetchOneOrNothing wId
|
case catResult of
|
||||||
case mWidget of
|
Left _ -> respondWithStatus 422 $ object
|
||||||
Nothing -> respondWithStatus 422 $ object
|
[ "error" .= ("Unregistered annotation category" :: Text)
|
||||||
["error" .= ("Widget not found" :: Text)]
|
, "code" .= ("unregistered_category" :: Text)
|
||||||
Just _widget -> do
|
, "value" .= cat
|
||||||
ann <- newRecord @Annotation
|
, "registry" .= ("/api/v2/annotation-categories" :: Text)
|
||||||
|> set #widgetId wId
|
]
|
||||||
|> set #category cat
|
Right () -> pure ()
|
||||||
|> set #body bodyTxt
|
|
||||||
|> set #actorType "api"
|
case UUID.fromText wIdText of
|
||||||
|> createRecord
|
Nothing -> respondWithStatus 422 $ object
|
||||||
renderJson (annotationToJson ann)
|
["error" .= ("widgetId must be a valid UUID" :: Text)]
|
||||||
|
Just rawId -> do
|
||||||
|
let wId = Id rawId :: Id Widget
|
||||||
|
mWidget <- fetchOneOrNothing wId
|
||||||
|
case mWidget of
|
||||||
|
Nothing -> respondWithStatus 422 $ object
|
||||||
|
["error" .= ("Widget not found" :: Text)]
|
||||||
|
Just _widget -> do
|
||||||
|
ann <- newRecord @Annotation
|
||||||
|
|> set #widgetId wId
|
||||||
|
|> set #category cat
|
||||||
|
|> set #body bodyTxt
|
||||||
|
|> set #actorType "api"
|
||||||
|
|> createRecord
|
||||||
|
respondWithStatus 201 (annotationToJson ann)
|
||||||
|
|
||||||
annotationToJson :: Annotation -> Value
|
annotationToJson :: Annotation -> Value
|
||||||
annotationToJson a = object
|
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
|
, "slug" .= hub.slug
|
||||||
, "domain" .= hub.domain
|
, "domain" .= hub.domain
|
||||||
, "hubKind" .= hub.hubKind
|
, "hubKind" .= hub.hubKind
|
||||||
|
, "hubFamily" .= hub.hubFamily
|
||||||
|
, "vsmFunction" .= hub.vsmFunction
|
||||||
|
, "vsmSystem" .= hub.vsmSystem
|
||||||
, "gaafStatus" .= gaafIndicator
|
, "gaafStatus" .= gaafIndicator
|
||||||
, "manifest" .= fmap manifestSummary mManifest
|
, "manifest" .= fmap manifestSummary mManifest
|
||||||
, "healthScore" .= fmap (.healthScore) mSnapshot
|
, "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 Generated.Types
|
||||||
import IHP.Prelude
|
import IHP.Prelude
|
||||||
import IHP.ControllerPrelude
|
import IHP.ControllerPrelude
|
||||||
import Data.Aeson (object, (.=))
|
import Data.Aeson (Value(..), object, (.=))
|
||||||
import qualified Data.Text as T
|
import IHP.ControllerSupport (getHeader, requestBodyJSON)
|
||||||
import Web.Controller.Api.V2.Auth
|
import Web.Controller.Api.V2.Auth
|
||||||
( requireApiConsumer, paginatedResponse, getPageParams
|
( requireApiConsumer, paginatedResponse, getPageParams
|
||||||
, respondWithStatus )
|
, respondWithStatus )
|
||||||
@@ -13,28 +13,22 @@ import Application.Helper.TypeRegistry (validateEventType)
|
|||||||
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
||||||
import Control.Concurrent (forkIO)
|
import Control.Concurrent (forkIO)
|
||||||
import Control.Monad (void)
|
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.UUID as UUID
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
|
import qualified Data.Vector as V
|
||||||
|
import Network.Wai (requestMethod)
|
||||||
|
|
||||||
instance Controller ApiV2InteractionEventsController where
|
instance Controller ApiV2InteractionEventsController where
|
||||||
|
|
||||||
action ApiV2IndexInteractionEventsAction = do
|
action ApiV2IndexInteractionEventsAction = do
|
||||||
_consumer <- requireApiConsumer
|
case requestMethod ?request of
|
||||||
(page, perPage) <- getPageParams
|
"GET" -> listInteractionEvents
|
||||||
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
|
"POST" -> createApiInteractionEvent
|
||||||
mEventType = paramOrNothing @Text "eventType"
|
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||||
let off = (page - 1) * perPage
|
|
||||||
let baseQ = query @InteractionEvent
|
|
||||||
|> orderByDesc #occurredAt
|
|
||||||
let q1 = case mWidgetId of
|
|
||||||
Just wId -> baseQ |> filterWhere (#widgetId, wId)
|
|
||||||
Nothing -> baseQ
|
|
||||||
let q2 = case mEventType of
|
|
||||||
Just et -> q1 |> filterWhere (#eventType, et)
|
|
||||||
Nothing -> q1
|
|
||||||
total <- q2 |> fetchCount
|
|
||||||
events <- q2 |> limit perPage |> offset off |> fetch
|
|
||||||
renderJson $ paginatedResponse (map eventToJson events) page perPage total
|
|
||||||
|
|
||||||
action ApiV2ShowInteractionEventAction { interactionEventId } = do
|
action ApiV2ShowInteractionEventAction { interactionEventId } = do
|
||||||
_consumer <- requireApiConsumer
|
_consumer <- requireApiConsumer
|
||||||
@@ -42,75 +36,97 @@ instance Controller ApiV2InteractionEventsController where
|
|||||||
renderJson (eventToJson event)
|
renderJson (eventToJson event)
|
||||||
|
|
||||||
-- POST /api/v2/interaction-events
|
-- POST /api/v2/interaction-events
|
||||||
action ApiV2CreateInteractionEventAction = do
|
action ApiV2CreateInteractionEventAction = createApiInteractionEvent
|
||||||
consumer <- requireApiConsumer
|
|
||||||
let widgetIdText = paramOrNothing @Text "widgetId"
|
|
||||||
eventType = paramOrNothing @Text "eventType"
|
|
||||||
viewContext = paramOrNothing @Text "viewContext"
|
|
||||||
|
|
||||||
let missing = catMaybes
|
listInteractionEvents :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||||
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
listInteractionEvents = do
|
||||||
, if isNothing eventType then Just "eventType" else Nothing
|
_consumer <- requireApiConsumer
|
||||||
]
|
(page, perPage) <- getPageParams
|
||||||
unless (null missing) do
|
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
|
||||||
respondWithStatus 422 $ object
|
mEventType = paramOrNothing @Text "eventType"
|
||||||
[ "error" .= ("Missing required fields" :: Text)
|
let off = (page - 1) * perPage
|
||||||
, "missing" .= missing
|
let baseQ = query @InteractionEvent
|
||||||
]
|
|> orderByDesc #occurredAt
|
||||||
|
let q1 = case mWidgetId of
|
||||||
|
Just wId -> baseQ |> filterWhere (#widgetId, wId)
|
||||||
|
Nothing -> baseQ
|
||||||
|
let q2 = case mEventType of
|
||||||
|
Just et -> q1 |> filterWhere (#eventType, et)
|
||||||
|
Nothing -> q1
|
||||||
|
total <- q2 |> fetchCount
|
||||||
|
events <- q2 |> limit perPage |> offset off |> fetch
|
||||||
|
renderJson $ paginatedResponse (map eventToJson events) page perPage total
|
||||||
|
|
||||||
let Just wIdText = widgetIdText
|
createApiInteractionEvent :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||||
Just evType = eventType
|
createApiInteractionEvent = do
|
||||||
|
consumer <- requireApiConsumer
|
||||||
|
metadata <- metadataFromRequest
|
||||||
|
let widgetIdText = paramOrNothing @Text "widgetId"
|
||||||
|
eventType = paramOrNothing @Text "eventType"
|
||||||
|
viewContext = paramOrNothing @Text "viewContext"
|
||||||
|
|
||||||
-- Validate against event_type_registry
|
let missing = catMaybes
|
||||||
evResult <- liftIO $ validateEventType evType
|
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
||||||
case evResult of
|
, if isNothing eventType then Just "eventType" else Nothing
|
||||||
Left _ -> respondWithStatus 422 $ object
|
]
|
||||||
[ "error" .= ("Unregistered event type" :: Text)
|
unless (null missing) do
|
||||||
, "code" .= ("unregistered_event_type" :: Text)
|
respondWithStatus 422 $ object
|
||||||
, "value" .= evType
|
[ "error" .= ("Missing required fields" :: Text)
|
||||||
, "registry" .= ("/api/v2/event-types" :: Text)
|
, "missing" .= missing
|
||||||
]
|
]
|
||||||
Right () -> pure ()
|
|
||||||
|
|
||||||
-- If consumer has a manifest, also validate against declared_event_types
|
let Just wIdText = widgetIdText
|
||||||
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
|
Just evType = eventType
|
||||||
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
|
|
||||||
respondWithStatus 422 $ object
|
|
||||||
[ "error" .= ("Event type not declared in hub manifest" :: Text)
|
|
||||||
, "code" .= ("event_type_not_in_manifest" :: Text)
|
|
||||||
, "value" .= evType
|
|
||||||
]
|
|
||||||
|
|
||||||
case UUID.fromText wIdText of
|
-- Validate against event_type_registry
|
||||||
Nothing -> respondWithStatus 422 $ object
|
evResult <- liftIO $ validateEventType evType
|
||||||
["error" .= ("widgetId must be a valid UUID" :: Text)]
|
case evResult of
|
||||||
Just rawId -> do
|
Left _ -> respondWithStatus 422 $ object
|
||||||
let wId = Id rawId :: Id Widget
|
[ "error" .= ("Unregistered event type" :: Text)
|
||||||
mWidget <- fetchOneOrNothing wId
|
, "code" .= ("unregistered_event_type" :: Text)
|
||||||
case mWidget of
|
, "value" .= evType
|
||||||
Nothing -> respondWithStatus 422 $ object
|
, "registry" .= ("/api/v2/event-types" :: Text)
|
||||||
["error" .= ("Widget not found" :: Text)]
|
]
|
||||||
Just _widget -> do
|
Right () -> pure ()
|
||||||
event <- newRecord @InteractionEvent
|
|
||||||
|> set #widgetId wId
|
-- If consumer has a manifest, also validate against declared_event_types
|
||||||
|> set #eventType evType
|
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
|
||||||
|> set #actorType "api"
|
manifest <- fetch manifestId
|
||||||
|> set #viewContextRef viewContext
|
when (manifest.status == "active") do
|
||||||
|> createRecord
|
unless (manifestAllowsEvent evType manifest.declaredEventTypes) do
|
||||||
-- Dispatch webhooks fire-and-forget
|
respondWithStatus 422 $ object
|
||||||
let webhookPayload = object
|
[ "error" .= ("Event type not declared in hub manifest" :: Text)
|
||||||
[ "event" .= ("interaction_event.created" :: Text)
|
, "code" .= ("event_type_not_in_manifest" :: Text)
|
||||||
, "resourceId" .= event.id
|
, "value" .= evType
|
||||||
, "widgetId" .= event.widgetId
|
]
|
||||||
, "eventType" .= event.eventType
|
|
||||||
, "occurredAt" .= event.occurredAt
|
case UUID.fromText wIdText of
|
||||||
]
|
Nothing -> respondWithStatus 422 $ object
|
||||||
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload
|
["error" .= ("widgetId must be a valid UUID" :: Text)]
|
||||||
renderJson (eventToJson event)
|
Just rawId -> do
|
||||||
|
let wId = Id rawId :: Id Widget
|
||||||
|
mWidget <- fetchOneOrNothing wId
|
||||||
|
case mWidget of
|
||||||
|
Nothing -> respondWithStatus 422 $ object
|
||||||
|
["error" .= ("Widget not found" :: Text)]
|
||||||
|
Just _widget -> do
|
||||||
|
event <- newRecord @InteractionEvent
|
||||||
|
|> set #widgetId wId
|
||||||
|
|> set #eventType evType
|
||||||
|
|> set #actorType "api"
|
||||||
|
|> set #viewContextRef viewContext
|
||||||
|
|> set #metadata metadata
|
||||||
|
|> createRecord
|
||||||
|
-- Dispatch webhooks fire-and-forget
|
||||||
|
let webhookPayload = object
|
||||||
|
[ "event" .= ("interaction_event.created" :: Text)
|
||||||
|
, "resourceId" .= event.id
|
||||||
|
, "widgetId" .= event.widgetId
|
||||||
|
, "eventType" .= event.eventType
|
||||||
|
, "occurredAt" .= event.occurredAt
|
||||||
|
]
|
||||||
|
liftIO $ void $ forkIO $ dispatchWebhooks evType webhookPayload
|
||||||
|
respondWithStatus 201 (eventToJson event)
|
||||||
|
|
||||||
eventToJson :: InteractionEvent -> Value
|
eventToJson :: InteractionEvent -> Value
|
||||||
eventToJson e = object
|
eventToJson e = object
|
||||||
@@ -123,3 +139,34 @@ eventToJson e = object
|
|||||||
, "metadata" .= e.metadata
|
, "metadata" .= e.metadata
|
||||||
, "occurredAt" .= e.occurredAt
|
, "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 IHP.ControllerPrelude
|
||||||
import Data.Aeson (object, (.=), Array, toJSON)
|
import Data.Aeson (object, (.=), Array, toJSON)
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
|
import qualified Data.Aeson.Key as K
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Text.Encoding as TE
|
import qualified Data.Text.Encoding as TE
|
||||||
import qualified Data.Yaml as Yaml -- yaml package
|
import qualified Data.Yaml as Yaml -- yaml package
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
import Application.Helper.TypeRegistry
|
import Application.Helper.TypeRegistry
|
||||||
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
|
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories
|
||||||
|
, activePolicyScopes )
|
||||||
import Network.HTTP.Types (status200)
|
import Network.HTTP.Types (status200)
|
||||||
import Network.Wai (responseLBS)
|
import Network.Wai (responseLBS)
|
||||||
|
|
||||||
@@ -47,10 +49,12 @@ buildOpenApiSpec = do
|
|||||||
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
|
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
|
||||||
eventTypes <- activeEventTypes
|
eventTypes <- activeEventTypes
|
||||||
annCats <- activeAnnotationCategories
|
annCats <- activeAnnotationCategories
|
||||||
|
policyScopes <- activePolicyScopes
|
||||||
|
|
||||||
let wtEnum = toJSON $ map (.name) allWidgetTypes
|
let wtEnum = toJSON $ map (.name) allWidgetTypes
|
||||||
let etEnum = toJSON $ map (.name) eventTypes
|
let etEnum = toJSON $ map (.name) eventTypes
|
||||||
let acEnum = toJSON $ map (.name) annCats
|
let acEnum = toJSON $ map (.name) annCats
|
||||||
|
let psEnum = toJSON $ map (.name) policyScopes
|
||||||
|
|
||||||
pure $ object
|
pure $ object
|
||||||
[ "openapi" .= ("3.1.0" :: Text)
|
[ "openapi" .= ("3.1.0" :: Text)
|
||||||
@@ -76,6 +80,10 @@ buildOpenApiSpec = do
|
|||||||
[ "type" .= ("string" :: Text)
|
[ "type" .= ("string" :: Text)
|
||||||
, "enum" .= acEnum
|
, "enum" .= acEnum
|
||||||
]
|
]
|
||||||
|
, "PolicyScope" .= object
|
||||||
|
[ "type" .= ("string" :: Text)
|
||||||
|
, "enum" .= psEnum
|
||||||
|
]
|
||||||
, "PaginationMeta" .= object
|
, "PaginationMeta" .= object
|
||||||
[ "type" .= ("object" :: Text)
|
[ "type" .= ("object" :: Text)
|
||||||
, "properties" .= object
|
, "properties" .= object
|
||||||
@@ -84,9 +92,22 @@ buildOpenApiSpec = do
|
|||||||
, "total" .= object ["type" .= ("integer" :: Text)]
|
, "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
|
, "Widget" .= widgetSchema
|
||||||
|
, "CreateWidgetRequest" .= createWidgetRequestSchema
|
||||||
, "InteractionEvent" .= interactionEventSchema
|
, "InteractionEvent" .= interactionEventSchema
|
||||||
|
, "CreateInteractionEventRequest" .= createInteractionEventRequestSchema
|
||||||
, "Annotation" .= annotationSchema
|
, "Annotation" .= annotationSchema
|
||||||
|
, "CreateAnnotationRequest" .= createAnnotationRequestSchema
|
||||||
, "RequirementCandidate" .= rcSchema
|
, "RequirementCandidate" .= rcSchema
|
||||||
, "DecisionRecord" .= drSchema
|
, "DecisionRecord" .= drSchema
|
||||||
, "DeploymentRecord" .= depSchema
|
, "DeploymentRecord" .= depSchema
|
||||||
@@ -94,6 +115,12 @@ buildOpenApiSpec = do
|
|||||||
, "OutcomeCorrelation" .= outcomeCorrelationSchema
|
, "OutcomeCorrelation" .= outcomeCorrelationSchema
|
||||||
, "PatternPerformanceRecord" .= patternPerformanceSchema
|
, "PatternPerformanceRecord" .= patternPerformanceSchema
|
||||||
, "InstitutionalKnowledgeEntry" .= institutionalKnowledgeSchema
|
, "InstitutionalKnowledgeEntry" .= institutionalKnowledgeSchema
|
||||||
|
, "HubRegistryEntry" .= hubRegistryEntrySchema
|
||||||
|
, "HubManifestSummary" .= hubManifestSummarySchema
|
||||||
|
, "WidgetPattern" .= widgetPatternSchema
|
||||||
|
, "WidgetPatternDetail" .= widgetPatternDetailSchema
|
||||||
|
, "WidgetPatternVersion" .= widgetPatternVersionSchema
|
||||||
|
, "PatternAdoptionResponse" .= patternAdoptionResponseSchema
|
||||||
]
|
]
|
||||||
, "securitySchemes" .= object
|
, "securitySchemes" .= object
|
||||||
[ "BearerAuth" .= object
|
[ "BearerAuth" .= object
|
||||||
@@ -108,7 +135,53 @@ buildOpenApiSpec = do
|
|||||||
|
|
||||||
buildPaths :: Value
|
buildPaths :: Value
|
||||||
buildPaths = object
|
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"
|
, "/widgets/{id}" .= getShowPath "Widget"
|
||||||
, "/interaction-events" .= object
|
, "/interaction-events" .= object
|
||||||
[ "get" .= listOp "InteractionEvent"
|
[ "get" .= listOp "InteractionEvent"
|
||||||
@@ -135,14 +208,19 @@ buildPaths = object
|
|||||||
, "/widget-types" .= publicListPath "WidgetTypeRegistry"
|
, "/widget-types" .= publicListPath "WidgetTypeRegistry"
|
||||||
, "/event-types" .= publicListPath "EventTypeRegistry"
|
, "/event-types" .= publicListPath "EventTypeRegistry"
|
||||||
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
|
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
|
||||||
|
, "/policy-scopes" .= publicListPath "PolicyScopeRegistry"
|
||||||
, "/token" .= tokenPath
|
, "/token" .= tokenPath
|
||||||
-- Phase 10 — Hub Registry and Widget Marketplace
|
-- Phase 10 — Hub Registry and Widget Marketplace
|
||||||
, "/hub-registry" .= getListPath "HubRegistryEntry"
|
, "/hub-registry" .= getListPath "HubRegistryEntry"
|
||||||
, "/hub-registry/{hubId}" .= getShowPath "HubRegistryEntry"
|
, "/hub-registry/{hubId}" .= getShowPathWithParam "HubRegistryEntry" "hubId"
|
||||||
, "/widget-patterns" .= getListPath "WidgetPattern"
|
, "/widget-patterns" .= getListPath "WidgetPattern"
|
||||||
, "/widget-patterns/{id}" .= getShowPath "WidgetPattern"
|
, "/widget-patterns/{id}" .= getShowPath "WidgetPatternDetail"
|
||||||
, "/widget-patterns/{id}/adopt" .= object
|
, "/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
|
getShowPath schemaName = object
|
||||||
[ "get" .= showOp schemaName ]
|
[ "get" .= showOp schemaName ]
|
||||||
|
|
||||||
|
getShowPathWithParam :: Text -> Text -> Value
|
||||||
|
getShowPathWithParam schemaName paramName = object
|
||||||
|
[ "get" .= showOpWithParam schemaName paramName ]
|
||||||
|
|
||||||
listOp :: Text -> [(Text, Text, Text)] -> Value
|
listOp :: Text -> [(Text, Text, Text)] -> Value
|
||||||
listOp schemaName extraParams = object
|
listOp schemaName extraParams = object
|
||||||
[ "summary" .= ("List " <> schemaName)
|
[ "summary" .= ("List " <> schemaName)
|
||||||
@@ -186,11 +268,45 @@ listOp schemaName extraParams = object
|
|||||||
, "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else [])
|
, "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 :: Text -> Value
|
||||||
showOp schemaName = object
|
showOp schemaName = showOpWithParam schemaName "id"
|
||||||
|
|
||||||
|
showOpWithParam :: Text -> Text -> Value
|
||||||
|
showOpWithParam schemaName paramName = object
|
||||||
[ "summary" .= ("Get " <> schemaName)
|
[ "summary" .= ("Get " <> schemaName)
|
||||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
, "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
|
, "responses" .= object
|
||||||
[ "200" .= object
|
[ "200" .= object
|
||||||
[ "description" .= ("OK" :: Text)
|
[ "description" .= ("OK" :: Text)
|
||||||
@@ -205,27 +321,73 @@ showOp schemaName = object
|
|||||||
]
|
]
|
||||||
|
|
||||||
writeOp :: Text -> Text -> Value
|
writeOp :: Text -> Text -> Value
|
||||||
writeOp schemaName _reqSchema = object
|
writeOp schemaName reqSchema = writeOpWithSummary ("Create " <> schemaName) schemaName reqSchema
|
||||||
[ "summary" .= ("Create " <> schemaName)
|
|
||||||
|
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])]]
|
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||||
|
, "parameters" .= params
|
||||||
, "requestBody" .= object
|
, "requestBody" .= object
|
||||||
[ "required" .= True
|
[ "required" .= bodyRequired
|
||||||
, "content" .= object
|
, "content" .= object
|
||||||
[ "application/json" .= object
|
[ "application/json" .= object
|
||||||
["schema" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]]
|
["schema" .= object ["$ref" .= ("#/components/schemas/" <> reqSchema)]]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
, "responses" .= object
|
, "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)]
|
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
|
||||||
, "422" .= object ["description" .= ("Validation error" :: 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 :: Text -> Value
|
||||||
publicListPath schemaName = object
|
publicListPath schemaName = object
|
||||||
[ "get" .= object
|
[ "get" .= object
|
||||||
[ "summary" .= ("List registered " <> schemaName <> " values" :: Text)
|
[ "summary" .= ("List registered " <> schemaName <> " values" :: Text)
|
||||||
|
, "security" .= ([] :: [Value])
|
||||||
, "responses" .= object
|
, "responses" .= object
|
||||||
[ "200" .= object ["description" .= ("OK" :: Text)] ]
|
[ "200" .= object ["description" .= ("OK" :: Text)] ]
|
||||||
]
|
]
|
||||||
@@ -266,6 +428,37 @@ pageParams =
|
|||||||
|
|
||||||
-- Schemas for all resource types
|
-- 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 :: Value
|
||||||
widgetSchema = object
|
widgetSchema = object
|
||||||
[ "type" .= ("object" :: Text)
|
[ "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 :: Value
|
||||||
interactionEventSchema = object
|
interactionEventSchema = object
|
||||||
[ "type" .= ("object" :: Text)
|
[ "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 :: Value
|
||||||
annotationSchema = object
|
annotationSchema = object
|
||||||
[ "type" .= ("object" :: Text)
|
[ "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 :: Value
|
||||||
rcSchema = object
|
rcSchema = object
|
||||||
[ "type" .= ("object" :: Text)
|
[ "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 :: Value
|
||||||
uuidProp = object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
|
uuidProp = object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
|
||||||
|
|
||||||
strProp :: Value
|
strProp :: Value
|
||||||
strProp = object ["type" .= ("string" :: Text)]
|
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 :: Value
|
||||||
dtProp = object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
|
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/widget-types
|
||||||
-- GET /api/v2/event-types
|
-- GET /api/v2/event-types
|
||||||
-- GET /api/v2/annotation-categories
|
-- GET /api/v2/annotation-categories
|
||||||
|
-- GET /api/v2/policy-scopes
|
||||||
|
|
||||||
import Web.Types
|
import Web.Types
|
||||||
import Generated.Types
|
import Generated.Types
|
||||||
@@ -16,24 +17,31 @@ instance Controller ApiV2RegistriesController where
|
|||||||
action ApiV2ListWidgetTypesAction = do
|
action ApiV2ListWidgetTypesAction = do
|
||||||
types <- query @WidgetTypeRegistry
|
types <- query @WidgetTypeRegistry
|
||||||
|> filterWhere (#status, "active")
|
|> filterWhere (#status, "active")
|
||||||
|> orderByAsc #label_
|
|> orderByAsc #name
|
||||||
|> fetch
|
|> fetch
|
||||||
renderJson $ map wtToJson types
|
renderJson $ map wtToJson types
|
||||||
|
|
||||||
action ApiV2ListEventTypesAction = do
|
action ApiV2ListEventTypesAction = do
|
||||||
types <- query @EventTypeRegistry
|
types <- query @EventTypeRegistry
|
||||||
|> filterWhere (#status, "active")
|
|> filterWhere (#status, "active")
|
||||||
|> orderByAsc #label_
|
|> orderByAsc #name
|
||||||
|> fetch
|
|> fetch
|
||||||
renderJson $ map etToJson types
|
renderJson $ map etToJson types
|
||||||
|
|
||||||
action ApiV2ListAnnotationCategoriesAction = do
|
action ApiV2ListAnnotationCategoriesAction = do
|
||||||
cats <- query @AnnotationCategoryRegistry
|
cats <- query @AnnotationCategoryRegistry
|
||||||
|> filterWhere (#status, "active")
|
|> filterWhere (#status, "active")
|
||||||
|> orderByAsc #label_
|
|> orderByAsc #name
|
||||||
|> fetch
|
|> fetch
|
||||||
renderJson $ map acToJson cats
|
renderJson $ map acToJson cats
|
||||||
|
|
||||||
|
action ApiV2ListPolicyScopesAction = do
|
||||||
|
scopes <- query @PolicyScopeRegistry
|
||||||
|
|> filterWhere (#status, "active")
|
||||||
|
|> orderByAsc #name
|
||||||
|
|> fetch
|
||||||
|
renderJson $ map psToJson scopes
|
||||||
|
|
||||||
wtToJson :: WidgetTypeRegistry -> Value
|
wtToJson :: WidgetTypeRegistry -> Value
|
||||||
wtToJson r = object
|
wtToJson r = object
|
||||||
[ "name" .= r.name
|
[ "name" .= r.name
|
||||||
@@ -60,3 +68,12 @@ acToJson r = object
|
|||||||
, "ownerHubId" .= r.ownerHubId
|
, "ownerHubId" .= r.ownerHubId
|
||||||
, "status" .= r.status
|
, "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 }) {"
|
, " async getWidgets(params?: { page?: number; perPage?: number }) {"
|
||||||
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
|
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
|
||||||
, " return this.fetch('/widgets' + q).then(r => r.json());"
|
, " 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 }) {"
|
, " async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) {"
|
||||||
, " const qs = new URLSearchParams();"
|
, " const qs = new URLSearchParams();"
|
||||||
, " if (params?.widgetId) qs.set('widgetId', params.widgetId);"
|
, " if (params?.widgetId) qs.set('widgetId', params.widgetId);"
|
||||||
@@ -149,9 +177,46 @@ pyClientClass = T.unlines
|
|||||||
, " with urllib.request.urlopen(req) as resp:"
|
, " with urllib.request.urlopen(req) as resp:"
|
||||||
, " return json.loads(resp.read())"
|
, " 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:"
|
, " def get_widgets(self, page: int = 1, per_page: int = 50) -> dict:"
|
||||||
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
|
, " 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:"
|
, " 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})"
|
, " 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 ''))"
|
, " return self._request('/interaction-events' + ('?' + qs if qs else ''))"
|
||||||
|
|||||||
@@ -4,28 +4,158 @@ import Web.Types
|
|||||||
import Generated.Types
|
import Generated.Types
|
||||||
import IHP.Prelude
|
import IHP.Prelude
|
||||||
import IHP.ControllerPrelude
|
import IHP.ControllerPrelude
|
||||||
import Data.Aeson (object, (.=), ToJSON, toJSON)
|
import Data.Aeson (Value, object, (.=))
|
||||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
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
|
instance Controller ApiV2WidgetsController where
|
||||||
|
|
||||||
action ApiV2IndexWidgetsAction = do
|
action ApiV2IndexWidgetsAction = do
|
||||||
_consumer <- requireApiConsumer
|
case requestMethod ?request of
|
||||||
(page, perPage) <- getPageParams
|
"GET" -> listWidgets
|
||||||
let pageOffset = (page - 1) * perPage
|
"POST" -> createApiWidget
|
||||||
total <- query @Widget |> fetchCount
|
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||||
widgets <- query @Widget
|
|
||||||
|> orderByDesc #createdAt
|
|
||||||
|> limit perPage
|
|
||||||
|> offset pageOffset
|
|
||||||
|> fetch
|
|
||||||
renderJson $ paginatedResponse (map widgetToJson widgets) page perPage total
|
|
||||||
|
|
||||||
action ApiV2ShowWidgetAction { widgetId } = do
|
action ApiV2ShowWidgetAction { widgetId } = do
|
||||||
_consumer <- requireApiConsumer
|
_consumer <- requireApiConsumer
|
||||||
widget <- fetch widgetId
|
widget <- fetch widgetId
|
||||||
renderJson (widgetToJson widget)
|
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
|
||||||
|
total <- query @Widget |> fetchCount
|
||||||
|
widgets <- query @Widget
|
||||||
|
|> orderByDesc #createdAt
|
||||||
|
|> limit perPage
|
||||||
|
|> offset pageOffset
|
||||||
|
|> fetch
|
||||||
|
renderJson $ paginatedResponse (map widgetToJson widgets) page perPage total
|
||||||
|
|
||||||
|
createApiWidget :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||||
|
createApiWidget = do
|
||||||
|
_consumer <- requireApiConsumer
|
||||||
|
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 :: Widget -> Value
|
||||||
widgetToJson w = object
|
widgetToJson w = object
|
||||||
[ "id" .= w.id
|
[ "id" .= w.id
|
||||||
@@ -39,3 +169,17 @@ widgetToJson w = object
|
|||||||
, "version" .= w.version
|
, "version" .= w.version
|
||||||
, "createdAt" .= w.createdAt
|
, "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
|
action WidgetTypeRegistryAction = do
|
||||||
entries <- query @WidgetTypeRegistry
|
entries <- query @WidgetTypeRegistry
|
||||||
|> orderByAsc #label_
|
|> orderByAsc #name
|
||||||
|> fetch
|
|> fetch
|
||||||
hubs <- query @Hub |> fetch
|
hubs <- query @Hub |> fetch
|
||||||
render WidgetTypesView { entries, hubs }
|
render WidgetTypesView { entries, hubs }
|
||||||
@@ -83,7 +83,7 @@ instance Controller TypeRegistriesController where
|
|||||||
|
|
||||||
action EventTypeRegistryAction = do
|
action EventTypeRegistryAction = do
|
||||||
entries <- query @EventTypeRegistry
|
entries <- query @EventTypeRegistry
|
||||||
|> orderByAsc #label_
|
|> orderByAsc #name
|
||||||
|> fetch
|
|> fetch
|
||||||
hubs <- query @Hub |> fetch
|
hubs <- query @Hub |> fetch
|
||||||
render EventTypesView { entries, hubs }
|
render EventTypesView { entries, hubs }
|
||||||
@@ -149,7 +149,7 @@ instance Controller TypeRegistriesController where
|
|||||||
|
|
||||||
action AnnotationCategoryRegistryAction = do
|
action AnnotationCategoryRegistryAction = do
|
||||||
entries <- query @AnnotationCategoryRegistry
|
entries <- query @AnnotationCategoryRegistry
|
||||||
|> orderByAsc #label_
|
|> orderByAsc #name
|
||||||
|> fetch
|
|> fetch
|
||||||
hubs <- query @Hub |> fetch
|
hubs <- query @Hub |> fetch
|
||||||
render AnnotationCategoriesView { entries, hubs }
|
render AnnotationCategoriesView { entries, hubs }
|
||||||
@@ -215,7 +215,7 @@ instance Controller TypeRegistriesController where
|
|||||||
|
|
||||||
action PolicyScopeRegistryAction = do
|
action PolicyScopeRegistryAction = do
|
||||||
entries <- query @PolicyScopeRegistry
|
entries <- query @PolicyScopeRegistry
|
||||||
|> orderByAsc #label_
|
|> orderByAsc #name
|
||||||
|> fetch
|
|> fetch
|
||||||
hubs <- query @Hub |> fetch
|
hubs <- query @Hub |> fetch
|
||||||
render PolicyScopesView { entries, hubs }
|
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.OpenApi ()
|
||||||
import Web.Controller.Api.V2.Token ()
|
import Web.Controller.Api.V2.Token ()
|
||||||
import Web.Controller.Api.V2.Sdk ()
|
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)
|
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
|
||||||
import Web.Controller.HubRegistry ()
|
import Web.Controller.HubRegistry ()
|
||||||
import Web.Controller.WidgetPatterns ()
|
import Web.Controller.WidgetPatterns ()
|
||||||
@@ -116,6 +119,9 @@ instance FrontController WebApplication where
|
|||||||
, parseRoute @ApiV2OpenApiController
|
, parseRoute @ApiV2OpenApiController
|
||||||
, parseRoute @ApiV2TokenController
|
, parseRoute @ApiV2TokenController
|
||||||
, parseRoute @ApiV2SdkController
|
, parseRoute @ApiV2SdkController
|
||||||
|
, parseRoute @ApiV2HubsController
|
||||||
|
, parseRoute @ApiV2HubCapabilityManifestsController
|
||||||
|
, parseRoute @ApiV2ApiConsumersController
|
||||||
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
|
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
|
||||||
, parseRoute @HubRegistryController
|
, parseRoute @HubRegistryController
|
||||||
, parseRoute @WidgetPatternsController
|
, parseRoute @WidgetPatternsController
|
||||||
@@ -147,7 +153,19 @@ instance InitControllerContext WebApplication where
|
|||||||
initAuthentication @User
|
initAuthentication @User
|
||||||
|
|
||||||
defaultLayout :: (?context :: ControllerContext, ?request :: Request) => Layout
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -160,44 +178,59 @@ defaultLayout inner = [hsx|
|
|||||||
<script src="/vendor/ihp-auto-refresh.js"></script>
|
<script src="/vendor/ihp-auto-refresh.js"></script>
|
||||||
<script src="/js/ihf-annotation-launcher.js"></script>
|
<script src="/js/ihf-annotation-launcher.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 text-gray-900">
|
<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 gap-6">
|
<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={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>
|
<div class="ml-auto flex items-center" style="gap:2rem">
|
||||||
<a href={TutorialAction} class="text-sm text-gray-600 hover:text-gray-900">Tutorial</a>
|
<div class="flex items-center" style="gap:1.75rem">
|
||||||
<a href={ExtensionGuideAction} class="text-sm text-gray-600 hover:text-gray-900">Extend</a>
|
<a href={CapabilitiesAction} class="text-sm text-gray-500 hover:text-gray-900">About</a>
|
||||||
<span class="text-gray-200">|</span>
|
<a href={TutorialAction} class="text-sm text-gray-500 hover:text-gray-900">Tutorial</a>
|
||||||
<a href={HubsAction} class="text-sm text-gray-600 hover:text-gray-900">Hubs</a>
|
<a href={ExtensionGuideAction} class="text-sm text-gray-500 hover:text-gray-900">Extend</a>
|
||||||
<a href={WidgetsAction} class="text-sm text-gray-600 hover:text-gray-900">Widgets</a>
|
</div>
|
||||||
<a href={RequirementCandidatesAction} class="text-sm text-gray-600 hover:text-gray-900">Candidates</a>
|
<span class="text-gray-200">|</span>
|
||||||
<a href={RequirementsAction} class="text-sm text-gray-600 hover:text-gray-900">Requirements</a>
|
{authWidget}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="max-w-5xl mx-auto px-6 py-8">
|
<div class="flex" style="flex:1">
|
||||||
{inner}
|
<aside class="w-48 bg-white border-r border-gray-200 flex-shrink-0 overflow-y-auto">
|
||||||
</main>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|]
|
|]
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ instance CanRoute ApiV2WidgetsController where
|
|||||||
instance HasPath ApiV2WidgetsController where
|
instance HasPath ApiV2WidgetsController where
|
||||||
pathTo ApiV2IndexWidgetsAction = "/api/v2/widgets"
|
pathTo ApiV2IndexWidgetsAction = "/api/v2/widgets"
|
||||||
pathTo ApiV2ShowWidgetAction { widgetId } = "/api/v2/widgets/" <> tshow widgetId
|
pathTo ApiV2ShowWidgetAction { widgetId } = "/api/v2/widgets/" <> tshow widgetId
|
||||||
|
pathTo ApiV2CreateWidgetAction = "/api/v2/widgets"
|
||||||
|
|
||||||
instance CanRoute ApiV2InteractionEventsController where
|
instance CanRoute ApiV2InteractionEventsController where
|
||||||
parseRoute' = do
|
parseRoute' = do
|
||||||
@@ -177,12 +178,14 @@ instance CanRoute ApiV2RegistriesController where
|
|||||||
[ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction
|
[ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction
|
||||||
, do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction
|
, do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction
|
||||||
, do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction
|
, do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction
|
||||||
|
, do _ <- string "policy-scopes"; endOfInput; pure ApiV2ListPolicyScopesAction
|
||||||
]
|
]
|
||||||
|
|
||||||
instance HasPath ApiV2RegistriesController where
|
instance HasPath ApiV2RegistriesController where
|
||||||
pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types"
|
pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types"
|
||||||
pathTo ApiV2ListEventTypesAction = "/api/v2/event-types"
|
pathTo ApiV2ListEventTypesAction = "/api/v2/event-types"
|
||||||
pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories"
|
pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories"
|
||||||
|
pathTo ApiV2ListPolicyScopesAction = "/api/v2/policy-scopes"
|
||||||
|
|
||||||
instance CanRoute ApiV2OpenApiController where
|
instance CanRoute ApiV2OpenApiController where
|
||||||
parseRoute' = do
|
parseRoute' = do
|
||||||
@@ -242,6 +245,61 @@ instance HasPath ApiV2HubRegistryController where
|
|||||||
pathTo ApiV2IndexHubRegistryAction = "/api/v2/hub-registry"
|
pathTo ApiV2IndexHubRegistryAction = "/api/v2/hub-registry"
|
||||||
pathTo ApiV2ShowHubRegistryAction { hubId } = "/api/v2/hub-registry/" <> tshow hubId
|
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
|
instance CanRoute ApiV2WidgetPatternsController where
|
||||||
parseRoute' = do
|
parseRoute' = do
|
||||||
_ <- string "/api/v2/widget-patterns"
|
_ <- string "/api/v2/widget-patterns"
|
||||||
|
|||||||
23
Web/Types.hs
23
Web/Types.hs
@@ -285,6 +285,7 @@ data ApiDashboardController
|
|||||||
data ApiV2WidgetsController
|
data ApiV2WidgetsController
|
||||||
= ApiV2IndexWidgetsAction
|
= ApiV2IndexWidgetsAction
|
||||||
| ApiV2ShowWidgetAction { widgetId :: !(Id Widget) }
|
| ApiV2ShowWidgetAction { widgetId :: !(Id Widget) }
|
||||||
|
| ApiV2CreateWidgetAction
|
||||||
deriving (Eq, Show, Data)
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
data ApiV2InteractionEventsController
|
data ApiV2InteractionEventsController
|
||||||
@@ -323,6 +324,7 @@ data ApiV2RegistriesController
|
|||||||
= ApiV2ListWidgetTypesAction
|
= ApiV2ListWidgetTypesAction
|
||||||
| ApiV2ListEventTypesAction
|
| ApiV2ListEventTypesAction
|
||||||
| ApiV2ListAnnotationCategoriesAction
|
| ApiV2ListAnnotationCategoriesAction
|
||||||
|
| ApiV2ListPolicyScopesAction
|
||||||
deriving (Eq, Show, Data)
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
data ApiV2OpenApiController
|
data ApiV2OpenApiController
|
||||||
@@ -400,6 +402,27 @@ data ApiV2HubRegistryController
|
|||||||
| ApiV2ShowHubRegistryAction { hubId :: !(Id Hub) }
|
| ApiV2ShowHubRegistryAction { hubId :: !(Id Hub) }
|
||||||
deriving (Eq, Show, Data)
|
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
|
data ApiV2WidgetPatternsController
|
||||||
= ApiV2IndexWidgetPatternsAction
|
= ApiV2IndexWidgetPatternsAction
|
||||||
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }
|
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ renderRow row@HubRegistryRow { hub, mManifest, mLatestSnapshot } =
|
|||||||
{hub.name}
|
{hub.name}
|
||||||
</a>
|
</a>
|
||||||
<span class="text-xs text-gray-400 font-mono">{hub.hubKind}</span>
|
<span class="text-xs text-gray-400 font-mono">{hub.hubKind}</span>
|
||||||
|
{classificationBadge hub}
|
||||||
{gaafBadge gs}
|
{gaafBadge gs}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||||
@@ -74,6 +75,17 @@ gaafBadge GaafDraftOnly =
|
|||||||
gaafBadge GaafNoManifest =
|
gaafBadge GaafNoManifest =
|
||||||
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">no manifest</span>|]
|
[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 :: Int -> Html
|
||||||
healthScoreBadge s =
|
healthScoreBadge s =
|
||||||
let cls :: Text
|
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">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">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">Kind</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Family</th>
|
||||||
<th class="px-4 py-3"></th>
|
<th class="px-4 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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 "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>|]
|
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 -> Html
|
||||||
renderHub hub = [hsx|
|
renderHub hub = [hsx|
|
||||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
<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 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 text-gray-500">{hub.domain}</td>
|
||||||
<td class="px-4 py-3">{kindBadge hub.hubKind}</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">
|
<td class="px-4 py-3 text-right">
|
||||||
<a href={EditHubAction (hub.id)}
|
<a href={EditHubAction (hub.id)}
|
||||||
class="text-gray-500 hover:text-gray-700 text-xs mr-3">Edit</a>
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-2xl font-semibold">{hub.name}</h1>
|
<h1 class="text-2xl font-semibold">{hub.name}</h1>
|
||||||
{kindBadge hub.hubKind}
|
{kindBadge hub.hubKind}
|
||||||
|
{classificationBadge hub}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 mt-1">
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
<span class="font-mono bg-gray-100 px-1 rounded">{hub.slug}</span>
|
<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 "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>|]
|
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 :: Maybe Text -> [Text]
|
||||||
maybeText Nothing = []
|
maybeText Nothing = []
|
||||||
maybeText (Just t) = [t]
|
maybeText (Just t) = [t]
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ typeForm entry hubs isNew = [hsx|
|
|||||||
{renderNameField isNew entry.name}
|
{renderNameField isNew entry.name}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
<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>
|
||||||
<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>
|
<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}
|
{renderNameField isNew entry.name}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
<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>
|
||||||
<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>
|
<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}
|
{renderNameField isNew entry.name}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
<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>
|
||||||
<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>
|
<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}
|
{renderNameField isNew entry.name}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
<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>
|
||||||
<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>
|
<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)
|
## 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`,
|
**OpenAPI spec:** `/api/v2/openapi.json` (live-generated; `widget_type`,
|
||||||
`event_type`, and `category` fields carry `enum` arrays from the type registries)
|
`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:**
|
**New endpoints in v2:**
|
||||||
- `POST /api/v2/token` — OAuth 2.0 client credentials token exchange
|
- `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
|
- `GET /api/v2/widgets` — paginated widget listing
|
||||||
|
- `POST /api/v2/widgets` — create a widget
|
||||||
- `GET /api/v2/interaction-events` — paginated event listing
|
- `GET /api/v2/interaction-events` — paginated event listing
|
||||||
- `POST /api/v2/interaction-events` — submit event (registry-validated)
|
- `POST /api/v2/interaction-events` — submit event (registry-validated)
|
||||||
- `GET /api/v2/annotations` — paginated annotation listing
|
- `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>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
118
docs/evidence/ops-hub-activity-core-fallback-validation.md
Normal file
118
docs/evidence/ops-hub-activity-core-fallback-validation.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Ops Hub Activity-Core Fallback Validation
|
||||||
|
|
||||||
|
Date: 2026-06-16
|
||||||
|
|
||||||
|
Workplan: `IHUB-WP-0022`
|
||||||
|
|
||||||
|
## Validation Result
|
||||||
|
|
||||||
|
The State Hub fallback path is implemented, locally tested in activity-core,
|
||||||
|
and now verified through a live Railiance01 activity-core manual trigger.
|
||||||
|
|
||||||
|
Direct query:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET http://127.0.0.1:8000/progress/?event_type=ops_inventory_probe&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed result on 2026-06-16:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "db408146-0310-4ac3-ac77-f73c5a41e070",
|
||||||
|
"event_type": "ops_inventory_probe",
|
||||||
|
"summary": "Ops inventory probe: 0 ok, 4 degraded, 0 down, 5 skipped",
|
||||||
|
"author": "activity-core",
|
||||||
|
"created_at": "2026-06-16T05:34:02.711888Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Railiance also posted verifier evidence note
|
||||||
|
`60256e9a-9d1b-44db-8999-738cf03bca2e`, proving that the progress event was
|
||||||
|
matched to the exact manual activity-core trigger run:
|
||||||
|
|
||||||
|
- manual workflow:
|
||||||
|
`activity-40d15a87-7ff6-4d8e-992c-37df15f95110:manual-d2daa0e4-2d54-430e-a957-dca0ec9f469d`
|
||||||
|
- matched activity-core run id:
|
||||||
|
`90e3b112-d1e3-51af-8fb2-cb61f26add17`
|
||||||
|
- matched fallback progress:
|
||||||
|
`db408146-0310-4ac3-ac77-f73c5a41e070`
|
||||||
|
- immutable runtime evidence:
|
||||||
|
`api_image_id=sha256:5ff92a8217c450ae06075d00862b6e2a92a83ca09eea18b5a5e96b5d2d728b35`
|
||||||
|
|
||||||
|
This means Inter-Hub can cite live fallback evidence as the continuity
|
||||||
|
artifact for activity-core while the governed Inter-Hub widget/API-key path
|
||||||
|
remains explicitly deferred.
|
||||||
|
|
||||||
|
## What Is Validated
|
||||||
|
|
||||||
|
Activity-core local tests and the Railiance01 verifier now 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`.
|
||||||
|
- Railiance verifier evidence correlates the State Hub progress event to the
|
||||||
|
exact manual activity-core trigger run id instead of accepting any fresh
|
||||||
|
`ops_inventory_probe`.
|
||||||
|
- The verifier evidence includes immutable runtime identity through the live
|
||||||
|
`actcore-api` container image digest.
|
||||||
|
|
||||||
|
## What Is Not Yet Validated
|
||||||
|
|
||||||
|
- 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` may remain closed using fallback-deferred closure.
|
||||||
|
The live fallback path now has non-secret State Hub evidence, and the
|
||||||
|
Inter-Hub submission path is explicitly deferred until `IHUB-WP-0022-T03`,
|
||||||
|
`IHUB-WP-0022-T04`, and `IHUB-WP-0022-T07` complete.
|
||||||
|
|
||||||
|
This is not full Inter-Hub activation. The remaining full activation path is:
|
||||||
|
|
||||||
|
1. Provision `OPS_HUB_KEY`.
|
||||||
|
2. Deploy `OPS_HUB_WIDGET_MAPPING`.
|
||||||
|
3. Seed and verify the ops-hub widgets.
|
||||||
|
4. Submit one accepted Inter-Hub event per activity-core event type.
|
||||||
|
|
||||||
|
Until that path is satisfied, Inter-Hub should keep its own per-entity intake
|
||||||
|
tasks open, but it does not need to hold the activity-core closure gate open.
|
||||||
|
|
||||||
|
## Next Evidence To Capture
|
||||||
|
|
||||||
|
- Confirmation that Inter-Hub sink remains skipped cleanly while config is
|
||||||
|
absent or deferred in the deployed runtime.
|
||||||
|
- After ops-hub activation, event ids for one accepted Inter-Hub submission per
|
||||||
|
event type.
|
||||||
609
docs/fdd/personal-dashboard-fdd.md
Normal file
609
docs/fdd/personal-dashboard-fdd.md
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
# Personal Dashboard Framework FDD
|
||||||
|
|
||||||
|
**Workplan:** IHUB-WP-0020
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Status:** Functional design for follow-on implementation workplan
|
||||||
|
**Inputs:** `docs/research/personal-dashboard-current-state.md`,
|
||||||
|
`docs/prs/personal-dashboard-prs.md`
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
The personal dashboard is an authenticated, per-user landing surface composed of
|
||||||
|
server-rendered, governed panels. It reuses existing inter-hub data and links to
|
||||||
|
existing source dashboards. It does not replace hub dashboards, governance
|
||||||
|
dashboards, API dashboard, marketplace, or learning dashboard.
|
||||||
|
|
||||||
|
First implementation should ship:
|
||||||
|
|
||||||
|
- one default dashboard per user, with schema ready for multiple dashboards;
|
||||||
|
- six first-slice panel types;
|
||||||
|
- persisted panel layout/config;
|
||||||
|
- stable widget identity for each saved panel;
|
||||||
|
- `widgetEnvelope` wrapping for every rendered panel;
|
||||||
|
- simple server-rendered edit forms;
|
||||||
|
- post-login redirect to the personal dashboard.
|
||||||
|
|
||||||
|
## 2. Design Decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Table prefix | Use `personal_dashboards`, `dashboard_panel_types`, and `dashboard_panels` |
|
||||||
|
| Panel type key field | Use `panel_key`, not `key`, to avoid ambiguous SQL/Haskell naming |
|
||||||
|
| Dashboard multiplicity | Schema supports multiple dashboards; first UI exposes only the default dashboard |
|
||||||
|
| Default dashboard | Created idempotently on first dashboard visit |
|
||||||
|
| Role defaults | No `users.role` column in first slice |
|
||||||
|
| Watched hubs | Represented in panel config for first slice, no separate watched-hub table |
|
||||||
|
| Panel widget ownership | Linked panel widgets are owned by the framework hub |
|
||||||
|
| Panel widget type | Use existing framework-level `panel` widget type |
|
||||||
|
| Panel removal | Soft-remove panel row with `removed_at`; archive linked widget |
|
||||||
|
| Rendering model | Controller/helper builds typed panel view models; views render pure HSX |
|
||||||
|
| Refresh model | Wrap the personal dashboard show action in `autoRefresh` initially |
|
||||||
|
| Client runtime | No JS framework and no client-side data fetch loop |
|
||||||
|
|
||||||
|
## 3. Schema
|
||||||
|
|
||||||
|
### 3.1 Migration Tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE personal_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 TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX personal_dashboards_one_default_idx
|
||||||
|
ON personal_dashboards (user_id)
|
||||||
|
WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX personal_dashboards_user_idx
|
||||||
|
ON personal_dashboards (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE dashboard_panel_types (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
panel_key TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
default_config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
default_col_span INTEGER NOT NULL DEFAULT 4,
|
||||||
|
default_row_span INTEGER NOT NULL DEFAULT 1,
|
||||||
|
live_update BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT dashboard_panel_types_span_check CHECK (
|
||||||
|
default_col_span BETWEEN 1 AND 12
|
||||||
|
AND default_row_span BETWEEN 1 AND 4
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX dashboard_panel_types_status_idx
|
||||||
|
ON dashboard_panel_types (status);
|
||||||
|
|
||||||
|
CREATE TABLE dashboard_panels (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
dashboard_id UUID NOT NULL REFERENCES personal_dashboards(id) ON DELETE CASCADE,
|
||||||
|
panel_type_id UUID NOT NULL REFERENCES dashboard_panel_types(id),
|
||||||
|
widget_id UUID NOT NULL REFERENCES widgets(id),
|
||||||
|
title TEXT,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
col INTEGER NOT NULL DEFAULT 0,
|
||||||
|
row INTEGER NOT NULL DEFAULT 0,
|
||||||
|
col_span INTEGER NOT NULL DEFAULT 4,
|
||||||
|
row_span INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
removed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
CONSTRAINT dashboard_panels_layout_check CHECK (
|
||||||
|
col BETWEEN 0 AND 11
|
||||||
|
AND row >= 0
|
||||||
|
AND col_span BETWEEN 1 AND 12
|
||||||
|
AND row_span BETWEEN 1 AND 4
|
||||||
|
AND col + col_span <= 12
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX dashboard_panels_dashboard_idx
|
||||||
|
ON dashboard_panels (dashboard_id, removed_at, row, col, sort_order);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX dashboard_panels_widget_idx
|
||||||
|
ON dashboard_panels (widget_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Seed Data
|
||||||
|
|
||||||
|
The implementation migration or seed helper must ensure:
|
||||||
|
|
||||||
|
- a framework hub exists with `hub_kind = 'framework'`;
|
||||||
|
- the framework-level `panel` widget type exists and is active;
|
||||||
|
- six `dashboard_panel_types` exist.
|
||||||
|
|
||||||
|
Seed panel types:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO dashboard_panel_types
|
||||||
|
(panel_key, label, description, default_config, default_col_span,
|
||||||
|
default_row_span, live_update)
|
||||||
|
VALUES
|
||||||
|
('watched-hubs', 'Watched Hubs',
|
||||||
|
'Hub list with latest health hints',
|
||||||
|
'{"limit":12,"displayMode":"compact"}', 6, 1, FALSE),
|
||||||
|
('recent-interactions', 'Recent Activity',
|
||||||
|
'Latest interaction events with widget and hub context',
|
||||||
|
'{"timeRange":"last24h","limit":25,"displayMode":"compact"}', 6, 1, TRUE),
|
||||||
|
('triage-queue', 'Triage Queue',
|
||||||
|
'Open requirement candidates waiting for triage',
|
||||||
|
'{"status":"open","limit":10,"sort":"oldest"}', 6, 1, TRUE),
|
||||||
|
('recent-decisions', 'Recent Decisions',
|
||||||
|
'Recent governance decisions across visible hubs',
|
||||||
|
'{"timeRange":"last30d","limit":10,"sort":"newest"}', 6, 1, FALSE),
|
||||||
|
('hub-health', 'Hub Health',
|
||||||
|
'Latest health snapshots and active bottleneck counts',
|
||||||
|
'{"limit":12,"displayMode":"compact"}', 6, 1, TRUE),
|
||||||
|
('learning-digest', 'Learning Digest',
|
||||||
|
'Recent learning insights and institutional knowledge highlights',
|
||||||
|
'{"insightLimit":5,"knowledgeLimit":5}', 6, 1, TRUE)
|
||||||
|
ON CONFLICT (panel_key) DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact framework hub seed should use existing hub invariants and avoid
|
||||||
|
creating a second framework hub. Recommended slug: `inter-hub`.
|
||||||
|
|
||||||
|
## 4. Haskell Types
|
||||||
|
|
||||||
|
### 4.1 Controller Type
|
||||||
|
|
||||||
|
Add to `Web.Types`:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data PersonalDashboardsController
|
||||||
|
= PersonalDashboardAction
|
||||||
|
| EditPersonalDashboardAction
|
||||||
|
| UpdatePersonalDashboardAction
|
||||||
|
| AddDashboardPanelAction
|
||||||
|
| UpdateDashboardPanelAction { dashboardPanelId :: !(Id DashboardPanel) }
|
||||||
|
| RemoveDashboardPanelAction { dashboardPanelId :: !(Id DashboardPanel) }
|
||||||
|
deriving (Eq, Show, Data)
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in:
|
||||||
|
|
||||||
|
- `Web/Routes.hs` with `instance AutoRoute PersonalDashboardsController`
|
||||||
|
- `Web/FrontController.hs` imports and controller list
|
||||||
|
- sidebar navigation as `Dashboard`
|
||||||
|
|
||||||
|
### 4.2 Config ADTs
|
||||||
|
|
||||||
|
Store panel config in JSONB. Decode into explicit Haskell types before querying:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data TimeRange
|
||||||
|
= Last24Hours
|
||||||
|
| Last7Days
|
||||||
|
| Last30Days
|
||||||
|
| AllTimeBounded
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
data DisplayMode
|
||||||
|
= Compact
|
||||||
|
| Detailed
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
data SortMode
|
||||||
|
= Newest
|
||||||
|
| Oldest
|
||||||
|
| HighestRisk
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
data DashboardPanelConfig
|
||||||
|
= WatchedHubsConfig
|
||||||
|
{ hubIds :: !(Maybe [Id Hub])
|
||||||
|
, limit :: !Int
|
||||||
|
, displayMode :: !DisplayMode
|
||||||
|
}
|
||||||
|
| RecentInteractionsConfig
|
||||||
|
{ hubIds :: !(Maybe [Id Hub])
|
||||||
|
, timeRange :: !TimeRange
|
||||||
|
, limit :: !Int
|
||||||
|
, displayMode :: !DisplayMode
|
||||||
|
}
|
||||||
|
| TriageQueueConfig
|
||||||
|
{ hubIds :: !(Maybe [Id Hub])
|
||||||
|
, status :: !Text
|
||||||
|
, limit :: !Int
|
||||||
|
, sortMode :: !SortMode
|
||||||
|
}
|
||||||
|
| RecentDecisionsConfig
|
||||||
|
{ hubIds :: !(Maybe [Id Hub])
|
||||||
|
, timeRange :: !TimeRange
|
||||||
|
, limit :: !Int
|
||||||
|
, sortMode :: !SortMode
|
||||||
|
}
|
||||||
|
| HubHealthConfig
|
||||||
|
{ hubIds :: !(Maybe [Id Hub])
|
||||||
|
, limit :: !Int
|
||||||
|
, displayMode :: !DisplayMode
|
||||||
|
}
|
||||||
|
| LearningDigestConfig
|
||||||
|
{ hubIds :: !(Maybe [Id Hub])
|
||||||
|
, insightLimit :: !Int
|
||||||
|
, knowledgeLimit :: !Int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation can place these in `Application/Helper/PersonalDashboard.hs`.
|
||||||
|
Config decoding should return warnings instead of crashing:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data PanelConfigResult
|
||||||
|
= PanelConfigOk DashboardPanelConfig
|
||||||
|
| PanelConfigWithWarnings DashboardPanelConfig [Text]
|
||||||
|
```
|
||||||
|
|
||||||
|
Clamp all limits server-side. Recommended default min/max:
|
||||||
|
|
||||||
|
- list panel limit: 1 to 50;
|
||||||
|
- hub list limit: 1 to 50;
|
||||||
|
- learning insight/knowledge limits: 1 to 20;
|
||||||
|
- column span: 1 to 12;
|
||||||
|
- row span: 1 to 4.
|
||||||
|
|
||||||
|
### 4.3 View Model ADT
|
||||||
|
|
||||||
|
Do not query from HSX views. Build typed view models in the controller/helper:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data PersonalDashboardViewModel = PersonalDashboardViewModel
|
||||||
|
{ dashboard :: !PersonalDashboard
|
||||||
|
, panels :: ![DashboardPanelViewModel]
|
||||||
|
, panelTypes :: ![DashboardPanelType]
|
||||||
|
}
|
||||||
|
|
||||||
|
data DashboardPanelViewModel
|
||||||
|
= WatchedHubsPanel DashboardPanel Widget [WatchedHubRow] [Text]
|
||||||
|
| RecentInteractionsPanel DashboardPanel Widget [RecentInteractionRow] [Text]
|
||||||
|
| TriageQueuePanel DashboardPanel Widget [TriageQueueRow] [Text]
|
||||||
|
| RecentDecisionsPanel DashboardPanel Widget [RecentDecisionRow] [Text]
|
||||||
|
| HubHealthPanel DashboardPanel Widget [HubHealthRow] [Text]
|
||||||
|
| LearningDigestPanel DashboardPanel Widget [LearningDigestRow] [Text]
|
||||||
|
| UnsupportedPanel DashboardPanel Widget Text [Text]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each row type should carry exactly the fields the view needs, plus source route
|
||||||
|
ids for link-outs.
|
||||||
|
|
||||||
|
## 5. Controller Flow
|
||||||
|
|
||||||
|
### 5.1 Show
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /PersonalDashboard
|
||||||
|
-> ensureIsUser
|
||||||
|
-> ensureDefaultDashboard currentUser
|
||||||
|
-> fetch active dashboard panels ordered by row, col, sort_order
|
||||||
|
-> build DashboardPanelViewModel for each panel
|
||||||
|
-> render ShowView
|
||||||
|
```
|
||||||
|
|
||||||
|
The first implementation may wrap `PersonalDashboardAction` in `autoRefresh do`.
|
||||||
|
|
||||||
|
### 5.2 Edit
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /PersonalDashboard/Edit
|
||||||
|
-> ensureIsUser
|
||||||
|
-> ensureDefaultDashboard currentUser
|
||||||
|
-> fetch active panels and active panel types
|
||||||
|
-> render EditView
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit view should show:
|
||||||
|
|
||||||
|
- panel title;
|
||||||
|
- panel type label;
|
||||||
|
- row, col, col span, row span;
|
||||||
|
- limit/time range/hub filter where supported;
|
||||||
|
- remove button;
|
||||||
|
- add panel form.
|
||||||
|
|
||||||
|
### 5.3 Update Layout/Config
|
||||||
|
|
||||||
|
`UpdatePersonalDashboardAction` should accept a simple form payload for all
|
||||||
|
active panels. It should:
|
||||||
|
|
||||||
|
- authorize that the dashboard belongs to current user;
|
||||||
|
- validate layout bounds;
|
||||||
|
- validate per-panel config;
|
||||||
|
- update dashboard/panel `updated_at`;
|
||||||
|
- redirect back to edit or show with success/error messages.
|
||||||
|
|
||||||
|
### 5.4 Add Panel
|
||||||
|
|
||||||
|
`AddDashboardPanelAction` should:
|
||||||
|
|
||||||
|
1. fetch current user's default dashboard;
|
||||||
|
2. fetch selected active `DashboardPanelType`;
|
||||||
|
3. find/create the framework hub;
|
||||||
|
4. create linked `Widget`;
|
||||||
|
5. create initial `WidgetVersion`;
|
||||||
|
6. create `DashboardPanel` with default config and next layout slot;
|
||||||
|
7. redirect to edit.
|
||||||
|
|
||||||
|
### 5.5 Remove Panel
|
||||||
|
|
||||||
|
`RemoveDashboardPanelAction { dashboardPanelId }` should:
|
||||||
|
|
||||||
|
1. verify the panel belongs to current user's dashboard;
|
||||||
|
2. set `dashboard_panels.removed_at`;
|
||||||
|
3. set linked widget `is_archived = TRUE` and `status = 'deprecated'`;
|
||||||
|
4. keep interaction events and annotations intact;
|
||||||
|
5. redirect to edit.
|
||||||
|
|
||||||
|
## 6. Default Dashboard Seeding
|
||||||
|
|
||||||
|
Recommended helper:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
ensureDefaultDashboard :: (?modelContext :: ModelContext) => User -> IO PersonalDashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1. Query default dashboard for user.
|
||||||
|
2. If found, return it.
|
||||||
|
3. If absent, create `personal_dashboards` row with name `My Dashboard`.
|
||||||
|
4. Fetch active first-slice `DashboardPanelType` rows.
|
||||||
|
5. Create one linked widget and one panel row for each seed panel.
|
||||||
|
6. Return the dashboard.
|
||||||
|
|
||||||
|
Default layout:
|
||||||
|
|
||||||
|
| Panel | col | row | col_span | row_span |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| watched-hubs | 0 | 0 | 6 | 1 |
|
||||||
|
| recent-interactions | 6 | 0 | 6 | 1 |
|
||||||
|
| triage-queue | 0 | 1 | 6 | 1 |
|
||||||
|
| recent-decisions | 6 | 1 | 6 | 1 |
|
||||||
|
| hub-health | 0 | 2 | 6 | 1 |
|
||||||
|
| learning-digest | 6 | 2 | 6 | 1 |
|
||||||
|
|
||||||
|
If a user has active stewardship roles matched by email or name, panel config
|
||||||
|
may include those hub ids. If no match exists, config should stay neutral.
|
||||||
|
|
||||||
|
## 7. Panel Renderer Query Shapes
|
||||||
|
|
||||||
|
All panel queries must be bounded and must not expose secrets.
|
||||||
|
|
||||||
|
### Watched Hubs
|
||||||
|
|
||||||
|
Purpose: show hub names, domains/kinds, latest health score if available, and a
|
||||||
|
link to `ShowHubAction`.
|
||||||
|
|
||||||
|
Query shape:
|
||||||
|
|
||||||
|
- fetch hubs matching optional hub filter, order by name, limit N;
|
||||||
|
- fetch latest health snapshots for those hub ids using `DISTINCT ON (hub_id)`
|
||||||
|
or equivalent bounded query.
|
||||||
|
|
||||||
|
### Recent Activity
|
||||||
|
|
||||||
|
Purpose: show recent interaction events with widget and hub context.
|
||||||
|
|
||||||
|
Query shape:
|
||||||
|
|
||||||
|
- filter by optional hub ids through widget join;
|
||||||
|
- filter by time range;
|
||||||
|
- order by `interaction_events.occurred_at DESC`;
|
||||||
|
- limit N;
|
||||||
|
- fetch widget/hub names for display.
|
||||||
|
|
||||||
|
### Triage Queue
|
||||||
|
|
||||||
|
Purpose: show open requirement candidates that need attention.
|
||||||
|
|
||||||
|
Query shape:
|
||||||
|
|
||||||
|
- filter `requirement_candidates.status = 'open'`;
|
||||||
|
- optionally filter by source widget hub;
|
||||||
|
- order oldest first by default;
|
||||||
|
- limit N;
|
||||||
|
- fetch source widget and hub names.
|
||||||
|
|
||||||
|
### Recent Decisions
|
||||||
|
|
||||||
|
Purpose: show governance decisions that changed recently.
|
||||||
|
|
||||||
|
Query shape:
|
||||||
|
|
||||||
|
- filter by time range on `decided_at` or `created_at` fallback;
|
||||||
|
- optionally filter by hub through requirement candidate source widget lineage;
|
||||||
|
- order newest first;
|
||||||
|
- limit N.
|
||||||
|
|
||||||
|
### Hub Health
|
||||||
|
|
||||||
|
Purpose: show latest health score and active bottleneck count.
|
||||||
|
|
||||||
|
Query shape:
|
||||||
|
|
||||||
|
- latest `hub_health_snapshots` per hub;
|
||||||
|
- active `bottleneck_records` count per hub;
|
||||||
|
- limit N hubs by health score ascending or hub name depending config.
|
||||||
|
|
||||||
|
When decoding aggregate counts as `Int`, cast `COUNT(*) AS integer` or decode
|
||||||
|
as `Int64`.
|
||||||
|
|
||||||
|
### Learning Digest
|
||||||
|
|
||||||
|
Purpose: show recent `learning_insights` and
|
||||||
|
`institutional_knowledge_entries`.
|
||||||
|
|
||||||
|
Query shape:
|
||||||
|
|
||||||
|
- optional hub filter;
|
||||||
|
- latest insights ordered by `computed_at DESC`, limit N;
|
||||||
|
- latest knowledge entries ordered by `created_at DESC`, limit N;
|
||||||
|
- link to source knowledge entries when available.
|
||||||
|
|
||||||
|
## 8. Layout
|
||||||
|
|
||||||
|
Desktop layout:
|
||||||
|
|
||||||
|
- 12-column CSS grid.
|
||||||
|
- panel spans come from `dashboard_panels.col_span` and `row_span`.
|
||||||
|
- layout ordering comes from row, col, sort order.
|
||||||
|
- gap should match existing dashboard spacing.
|
||||||
|
|
||||||
|
Mobile/narrow layout:
|
||||||
|
|
||||||
|
- collapse to a single column.
|
||||||
|
- ignore column positions visually.
|
||||||
|
- preserve row/sort ordering.
|
||||||
|
|
||||||
|
Implementation approach:
|
||||||
|
|
||||||
|
- add a small CSS helper in `static/app.css` if inline styles cannot express the
|
||||||
|
responsive collapse cleanly;
|
||||||
|
- keep panel headings at compact dashboard scale;
|
||||||
|
- avoid nested cards;
|
||||||
|
- keep source link and annotate control visible but quiet.
|
||||||
|
|
||||||
|
## 9. Routing and Navigation
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
- `PersonalDashboardAction`
|
||||||
|
- `EditPersonalDashboardAction`
|
||||||
|
- `UpdatePersonalDashboardAction`
|
||||||
|
- `AddDashboardPanelAction`
|
||||||
|
- `UpdateDashboardPanelAction`
|
||||||
|
- `RemoveDashboardPanelAction`
|
||||||
|
|
||||||
|
Expected user-facing routes with AutoRoute naming are acceptable. If a cleaner
|
||||||
|
path is desired, add explicit `HasPath`/`CanRoute` later. First implementation
|
||||||
|
can use AutoRoute for speed and consistency.
|
||||||
|
|
||||||
|
Update login:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
login user
|
||||||
|
redirectTo PersonalDashboardAction
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not alter:
|
||||||
|
|
||||||
|
- public `LandingAction`;
|
||||||
|
- docs/tutorial/extension guide routes;
|
||||||
|
- existing `HubsAction` route.
|
||||||
|
|
||||||
|
## 10. Governance Lifecycle
|
||||||
|
|
||||||
|
### Panel Add
|
||||||
|
|
||||||
|
- create `DashboardPanel`;
|
||||||
|
- create linked `Widget`;
|
||||||
|
- create initial `WidgetVersion` snapshot with panel type and config;
|
||||||
|
- render through `widgetEnvelope`.
|
||||||
|
|
||||||
|
### Panel Update
|
||||||
|
|
||||||
|
- update `DashboardPanel.config` and layout fields;
|
||||||
|
- update panel widget name/view context only if needed;
|
||||||
|
- create a new `WidgetVersion` snapshot when config changes materially.
|
||||||
|
|
||||||
|
### Panel Remove
|
||||||
|
|
||||||
|
- set `dashboard_panels.removed_at`;
|
||||||
|
- set widget `is_archived = TRUE`;
|
||||||
|
- set widget `status = 'deprecated'`;
|
||||||
|
- preserve events and annotations.
|
||||||
|
|
||||||
|
### Annotation
|
||||||
|
|
||||||
|
The existing `WidgetAnnotationsAction` should work because panels have stable
|
||||||
|
widget ids.
|
||||||
|
|
||||||
|
### Event Capture
|
||||||
|
|
||||||
|
Existing client-side capture can identify panels via `data-widget-id`. If panel
|
||||||
|
forms submit through normal controller actions, use existing event types where
|
||||||
|
possible (`viewed`, `clicked`, `submitted`, `commented`).
|
||||||
|
|
||||||
|
## 11. Error Handling
|
||||||
|
|
||||||
|
- Missing dashboard: create default.
|
||||||
|
- Missing panel type: render `UnsupportedPanel` with warning.
|
||||||
|
- Invalid config: use defaults and render warning.
|
||||||
|
- Missing linked widget: repair by creating a replacement widget if possible,
|
||||||
|
otherwise render unsupported warning.
|
||||||
|
- Missing framework hub: create the framework hub if absent, honoring unique
|
||||||
|
framework hub constraint.
|
||||||
|
- Empty panel data: render a quiet empty state.
|
||||||
|
|
||||||
|
## 12. Tests and Smoke Checks
|
||||||
|
|
||||||
|
Focused automated checks:
|
||||||
|
|
||||||
|
- `ensureDefaultDashboard` is idempotent.
|
||||||
|
- seeded dashboard contains six active panels.
|
||||||
|
- each seeded panel has a linked widget.
|
||||||
|
- config decoder clamps limits and rejects unknown values safely.
|
||||||
|
- unauthorized user cannot edit another user's dashboard.
|
||||||
|
- remove action soft-removes panel and archives widget.
|
||||||
|
|
||||||
|
Manual smoke:
|
||||||
|
|
||||||
|
1. Log in as the seeded admin user.
|
||||||
|
2. Confirm redirect lands on personal dashboard.
|
||||||
|
3. Confirm all six seeded panels render.
|
||||||
|
4. Click source links from at least three panels.
|
||||||
|
5. Open Annotate for one panel and confirm existing annotation flow loads.
|
||||||
|
6. Edit layout, save, sign out/in, and confirm layout persists.
|
||||||
|
7. Add a panel and remove a panel.
|
||||||
|
8. Confirm `HubsAction`, hub show, Ops Review, Learning, API Dashboard, and
|
||||||
|
Marketplace still load.
|
||||||
|
|
||||||
|
## 13. Implementation File Map
|
||||||
|
|
||||||
|
Expected files for WP-0021:
|
||||||
|
|
||||||
|
- `Application/Migration/<timestamp>-personal-dashboard-framework.sql`
|
||||||
|
- `Application/Helper/PersonalDashboard.hs`
|
||||||
|
- `Web/Controller/PersonalDashboards.hs`
|
||||||
|
- `Web/View/PersonalDashboards/Show.hs`
|
||||||
|
- `Web/View/PersonalDashboards/Edit.hs`
|
||||||
|
- `Web/Types.hs`
|
||||||
|
- `Web/Routes.hs`
|
||||||
|
- `Web/FrontController.hs`
|
||||||
|
- `static/app.css` only if needed for responsive grid helpers
|
||||||
|
- focused tests under `Test/` if the current test harness supports controller or
|
||||||
|
helper tests
|
||||||
|
|
||||||
|
## 14. Open Questions
|
||||||
|
|
||||||
|
These do not block WP-0021, but should be revisited after the first
|
||||||
|
implementation:
|
||||||
|
|
||||||
|
1. Should personal dashboards later support team/shared dashboards?
|
||||||
|
2. Should watched hubs become a first-class table after users start editing
|
||||||
|
dashboards?
|
||||||
|
3. Should per-panel refresh be extracted into fragment routes?
|
||||||
|
4. Should dashboard panel widgets eventually be owned by source hubs instead of
|
||||||
|
the framework hub?
|
||||||
|
5. Should dashboard templates become part of the marketplace?
|
||||||
|
|
||||||
|
## 15. Handoff to WP-0021
|
||||||
|
|
||||||
|
WP-0021 should implement this design in small slices:
|
||||||
|
|
||||||
|
1. schema and seeds;
|
||||||
|
2. controller/route skeleton and default seeding;
|
||||||
|
3. first three panel view models/renderers;
|
||||||
|
4. dashboard show view;
|
||||||
|
5. remaining panel view models/renderers;
|
||||||
|
6. edit flow;
|
||||||
|
7. governance lifecycle;
|
||||||
|
8. login redirect and navigation;
|
||||||
|
9. tests and smoke.
|
||||||
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` |
|
||||||
340
docs/prs/personal-dashboard-prs.md
Normal file
340
docs/prs/personal-dashboard-prs.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Personal Dashboard Framework PRS
|
||||||
|
|
||||||
|
**Workplan:** IHUB-WP-0020
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Status:** Product requirements for follow-on FDD and implementation planning
|
||||||
|
**Research input:** `docs/research/personal-dashboard-current-state.md`
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
Authenticated inter-hub users currently land on the Hubs list after login. That
|
||||||
|
list is a useful management table, but it does not answer the daily operating
|
||||||
|
questions users bring to inter-hub:
|
||||||
|
|
||||||
|
- What changed recently?
|
||||||
|
- Which candidates or governance items need attention?
|
||||||
|
- Which hubs are unhealthy or blocked?
|
||||||
|
- Which learning or institutional knowledge signals should I notice today?
|
||||||
|
- Where should I go next?
|
||||||
|
|
||||||
|
Inter-hub already has many specialized dashboards, but they are scattered across
|
||||||
|
hub-level and platform-level routes. Users must know which surface to visit and
|
||||||
|
manually stitch together recent activity, open work, health, governance, and
|
||||||
|
learning signals.
|
||||||
|
|
||||||
|
The personal dashboard should become the authenticated landing surface that
|
||||||
|
summarizes the most relevant existing signals and links users to the source
|
||||||
|
dashboards for detail. It should be persistent, configurable, server-rendered,
|
||||||
|
and governed by the same IHF widget, annotation, and interaction-event model as
|
||||||
|
the rest of the application.
|
||||||
|
|
||||||
|
## 2. Users and Personas
|
||||||
|
|
||||||
|
### Hub Operator
|
||||||
|
|
||||||
|
Owns or watches one or more hubs. Needs quick visibility into recent events,
|
||||||
|
open requirement candidates, hub health, active bottlenecks, and regressions.
|
||||||
|
|
||||||
|
Primary questions:
|
||||||
|
|
||||||
|
- Which hubs need attention now?
|
||||||
|
- What happened since the last session?
|
||||||
|
- Which candidates are still open?
|
||||||
|
- Are any bottlenecks or health drops visible?
|
||||||
|
|
||||||
|
### Governance Reviewer
|
||||||
|
|
||||||
|
Triages candidates, reviews decisions, checks policy coverage, and follows
|
||||||
|
traceability from observations to implementation outcomes.
|
||||||
|
|
||||||
|
Primary questions:
|
||||||
|
|
||||||
|
- Which candidates are waiting for triage?
|
||||||
|
- Which decisions or requirements changed recently?
|
||||||
|
- Which panels need annotation or review?
|
||||||
|
- Are governance signals visible without visiting every hub?
|
||||||
|
|
||||||
|
### AI Orchestrator
|
||||||
|
|
||||||
|
Monitors agent proposals, review outcomes, learning signals, and institutional
|
||||||
|
knowledge that may affect future AI-assisted work.
|
||||||
|
|
||||||
|
Primary questions:
|
||||||
|
|
||||||
|
- Which agent proposals or reviews need attention?
|
||||||
|
- What learning insights were generated recently?
|
||||||
|
- Which knowledge entries should inform the next work session?
|
||||||
|
- Are there patterns of repeated friction or successful reuse?
|
||||||
|
|
||||||
|
### Platform Admin
|
||||||
|
|
||||||
|
Watches the inter-hub platform itself: API consumers, hub registry health,
|
||||||
|
manifests, policy overlays, marketplace activity, and cross-hub propagation.
|
||||||
|
|
||||||
|
Primary questions:
|
||||||
|
|
||||||
|
- Are API consumers active and healthy?
|
||||||
|
- Are hub manifests and registry views coherent?
|
||||||
|
- Are cross-hub governance or propagation signals emerging?
|
||||||
|
- Which operational panels should be promoted into a shared view later?
|
||||||
|
|
||||||
|
## 3. Product Goals
|
||||||
|
|
||||||
|
- Replace the authenticated post-login Hubs table as the daily landing surface.
|
||||||
|
- Provide a compact, configurable overview of existing inter-hub signals.
|
||||||
|
- Preserve the existing specialized dashboards as source-of-truth detail views.
|
||||||
|
- Make every saved panel a governed IHF interaction artifact.
|
||||||
|
- Keep the first implementation simple enough to deliver without a broad
|
||||||
|
dashboard refactor.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- Do not build a drag-and-drop report builder.
|
||||||
|
- Do not add external datasource connectors.
|
||||||
|
- Do not introduce a client-side data fetching framework.
|
||||||
|
- Do not refactor all existing dashboards into reusable panel modules in the
|
||||||
|
first slice.
|
||||||
|
- Do not add a full role/permission system.
|
||||||
|
- Do not make shared/team dashboards part of the first implementation.
|
||||||
|
- Do not change public root, tutorial, capabilities, or extension-guide routes.
|
||||||
|
|
||||||
|
## 5. Core Requirements
|
||||||
|
|
||||||
|
### Must
|
||||||
|
|
||||||
|
- Provide an authenticated personal dashboard route.
|
||||||
|
- Redirect successful login to the personal dashboard instead of `HubsAction`.
|
||||||
|
- Preserve public root behavior for unauthenticated and documentation users.
|
||||||
|
- Persist at least one dashboard per user.
|
||||||
|
- Seed a default dashboard on first visit.
|
||||||
|
- Persist panel instances, layout position, and panel config.
|
||||||
|
- Render all first-slice panels server-side through IHP/HSX.
|
||||||
|
- Use a server-side panel catalogue with stable panel keys.
|
||||||
|
- Bound every panel query by limit and, where relevant, hub/status/time filters.
|
||||||
|
- Decode JSONB panel config into explicit Haskell config types before querying.
|
||||||
|
- Create or reference stable `Widget` records for saved panel instances.
|
||||||
|
- Wrap rendered panels with `widgetEnvelope`.
|
||||||
|
- Preserve annotation and interaction-event identity across sessions.
|
||||||
|
- Link each panel to its existing source dashboard or source entity list.
|
||||||
|
- Provide a simple edit mode for adding, removing, and reordering panels through
|
||||||
|
normal IHP forms.
|
||||||
|
|
||||||
|
### Should
|
||||||
|
|
||||||
|
- Support hub filters for panels backed by hub-owned data.
|
||||||
|
- Support simple time-range filters where the underlying table has timestamps.
|
||||||
|
- Support limit/display-mode settings for panels with list content.
|
||||||
|
- Refresh recent activity, triage, health, and learning panels using the
|
||||||
|
existing `autoRefresh` style.
|
||||||
|
- Keep forms keyboard accessible and understandable without custom JavaScript.
|
||||||
|
- Render a neutral empty state when a panel has no data.
|
||||||
|
- Provide safe fallback behavior for invalid panel config.
|
||||||
|
- Keep first paint sub-second for a seeded dashboard with default panels.
|
||||||
|
- Use existing Tailwind/card/table visual conventions.
|
||||||
|
|
||||||
|
### Could
|
||||||
|
|
||||||
|
- Add saved watched-hub sets.
|
||||||
|
- Add multiple named dashboards per user.
|
||||||
|
- Add dashboard templates.
|
||||||
|
- Add shared/team dashboards.
|
||||||
|
- Add more panel types after the first framework slice is proven.
|
||||||
|
- Add finer-grained panel refresh routes later.
|
||||||
|
- Add user-selected default landing dashboard later.
|
||||||
|
|
||||||
|
### Won't for First Implementation
|
||||||
|
|
||||||
|
- Drag-and-drop layout editing.
|
||||||
|
- Mobile-native layout editor.
|
||||||
|
- Client-side data fetching.
|
||||||
|
- External dashboard or BI embedding.
|
||||||
|
- External datasource credentials.
|
||||||
|
- Role-based access control beyond existing authenticated controller guards.
|
||||||
|
- Complex visual charting library integration.
|
||||||
|
|
||||||
|
## 6. First-Slice Panel Catalogue
|
||||||
|
|
||||||
|
The first implementation should prove the framework with a small panel set.
|
||||||
|
|
||||||
|
| Panel key | Label | Purpose | Default behavior |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `watched-hubs` | Watched Hubs | Show hub list plus latest health hint | All hubs, limit 12 |
|
||||||
|
| `recent-interactions` | Recent Activity | Show latest interaction events with hub/widget context | Last 24h, limit 25 |
|
||||||
|
| `triage-queue` | Triage Queue | Show open requirement candidates | Open status, oldest first, limit 10 |
|
||||||
|
| `recent-decisions` | Recent Decisions | Show recent governance decisions | Last 30 days, newest first, limit 10 |
|
||||||
|
| `hub-health` | Hub Health | Show latest health snapshot and active blockers | Latest per hub, limit 12 |
|
||||||
|
| `learning-digest` | Learning Digest | Show latest insights and knowledge highlights | Latest insights and entries, limit 5 each |
|
||||||
|
|
||||||
|
Deferred panel catalogue candidates:
|
||||||
|
|
||||||
|
- `agent-proposals`
|
||||||
|
- `api-usage`
|
||||||
|
- `marketplace-trending`
|
||||||
|
- `my-annotations`
|
||||||
|
- `policy-compliance`
|
||||||
|
- `adapter-compatibility`
|
||||||
|
- `cross-hub-propagations`
|
||||||
|
|
||||||
|
## 7. Functional Requirements
|
||||||
|
|
||||||
|
### Dashboard Route
|
||||||
|
|
||||||
|
- Add a `PersonalDashboardsController` or similarly named controller.
|
||||||
|
- Add a show action for the current user's default dashboard.
|
||||||
|
- Add edit/update/add/remove actions for panel management.
|
||||||
|
- Register routes in `Web.Routes` and `Web.FrontController`.
|
||||||
|
- Add a sidebar link labelled `Dashboard`.
|
||||||
|
|
||||||
|
### Dashboard Persistence
|
||||||
|
|
||||||
|
- Store dashboard ownership by `users.id`.
|
||||||
|
- Support at least one default dashboard per user.
|
||||||
|
- Store panel order and grid layout.
|
||||||
|
- Store panel config in JSONB.
|
||||||
|
- Store a linked panel widget id for governance.
|
||||||
|
- Keep panel deletion non-destructive with respect to historical events and
|
||||||
|
annotations.
|
||||||
|
|
||||||
|
### Default Seeding
|
||||||
|
|
||||||
|
- On first visit, create a default dashboard for the authenticated user.
|
||||||
|
- Seed the first-slice panels with safe default config.
|
||||||
|
- Do not require a `users.role` column.
|
||||||
|
- Best-effort hub relevance may use active `stewardship_roles.assigned_to`
|
||||||
|
matching user email or name, but the neutral default must work without it.
|
||||||
|
|
||||||
|
### Panel Rendering
|
||||||
|
|
||||||
|
- Dispatch panels by stable catalogue key.
|
||||||
|
- Decode and validate config before querying.
|
||||||
|
- Use bounded queries.
|
||||||
|
- Render empty states and config warnings without crashing the whole dashboard.
|
||||||
|
- Wrap every rendered panel in `widgetEnvelope`.
|
||||||
|
- Link to source dashboards for deeper work.
|
||||||
|
|
||||||
|
### Edit Mode
|
||||||
|
|
||||||
|
- List current panels in layout order.
|
||||||
|
- Allow adding a panel from active panel types.
|
||||||
|
- Allow removing a panel from the dashboard.
|
||||||
|
- Allow editing column, row, span, limit, hub filter, time range, and display
|
||||||
|
mode where supported.
|
||||||
|
- Validate layout spans and config values server-side.
|
||||||
|
- Keep forms usable without custom JavaScript.
|
||||||
|
|
||||||
|
### Governance and Event Capture
|
||||||
|
|
||||||
|
- Saved panels must use stable `Widget` rows.
|
||||||
|
- Panel widgets should use the existing `panel` widget type.
|
||||||
|
- Panel widget `view_context` must be non-empty.
|
||||||
|
- Panel annotations must attach to the panel widget id.
|
||||||
|
- Panel interaction capture should use existing event types where possible.
|
||||||
|
- Adding/removing/reconfiguring panels should not mutate historical
|
||||||
|
interaction events.
|
||||||
|
|
||||||
|
## 8. Non-Functional Requirements
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Default dashboard first paint target: under 1 second in local development
|
||||||
|
with seeded fixtures.
|
||||||
|
- Each panel query should have a default row limit.
|
||||||
|
- Any aggregate query decoded as `Int` must cast `COUNT(*)` to integer or
|
||||||
|
decode as `Int64`.
|
||||||
|
- Avoid N+1 patterns where a simple join or batched fetch is practical.
|
||||||
|
- Use existing indexes where available; document new index needs in the FDD.
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
|
||||||
|
- Invalid panel config should not break the whole dashboard.
|
||||||
|
- Missing source data should render an empty state.
|
||||||
|
- Missing linked widget should be repaired or reported by the controller before
|
||||||
|
rendering.
|
||||||
|
- Dashboard seeding should be idempotent.
|
||||||
|
- Login redirect should fall back gracefully if dashboard seeding fails.
|
||||||
|
|
||||||
|
### Security and Privacy
|
||||||
|
|
||||||
|
- Dashboard routes require authenticated users.
|
||||||
|
- Users can view and edit only their own personal dashboard in the first slice.
|
||||||
|
- No secrets, API keys, or token values may be shown in dashboard panels.
|
||||||
|
- API usage panels must show only non-secret consumer metadata.
|
||||||
|
- Panel config must not become an arbitrary SQL or route execution surface.
|
||||||
|
|
||||||
|
### Accessibility and Usability
|
||||||
|
|
||||||
|
- Use semantic headings for panels.
|
||||||
|
- Use tables/lists for scan-heavy operational data.
|
||||||
|
- Use form labels for all edit inputs.
|
||||||
|
- Keep navigation links clear and predictable.
|
||||||
|
- Do not rely on hover-only controls for essential edits.
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
|
||||||
|
- Put renderer dispatch and config decoding in a focused helper/module.
|
||||||
|
- Keep panel renderer functions small and testable.
|
||||||
|
- Avoid moving existing dashboard code unless required.
|
||||||
|
- Prefer additive schema migrations.
|
||||||
|
- Keep first implementation tasks small enough for separate Codex sessions.
|
||||||
|
|
||||||
|
## 9. Acceptance Criteria
|
||||||
|
|
||||||
|
The product design is acceptable when the FDD can specify:
|
||||||
|
|
||||||
|
- Exact schema tables and fields.
|
||||||
|
- Exact controller/action names.
|
||||||
|
- Exact default panel seed set.
|
||||||
|
- Exact panel config types and defaults.
|
||||||
|
- Exact first-slice panel query shapes.
|
||||||
|
- Exact governance identity lifecycle for panel widgets.
|
||||||
|
- Exact smoke tests for login, dashboard seeding, editing, annotation, and
|
||||||
|
source dashboard link-outs.
|
||||||
|
|
||||||
|
The implementation will be acceptable when:
|
||||||
|
|
||||||
|
- A new authenticated user lands on a seeded personal dashboard after login.
|
||||||
|
- The seeded dashboard renders all first-slice panels.
|
||||||
|
- The user can add, remove, and adjust panels through server-rendered forms.
|
||||||
|
- Panel layout persists across sessions.
|
||||||
|
- Each panel is wrapped in `widgetEnvelope`.
|
||||||
|
- Annotating a panel opens the existing widget annotation flow.
|
||||||
|
- Existing Hubs and specialized dashboard routes still load.
|
||||||
|
- Focused tests or smoke checks cover seeding, config validation, route access,
|
||||||
|
and bounded query behavior.
|
||||||
|
|
||||||
|
## 10. Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Scope grows into report builder | Limit first slice to six panel types and server-rendered forms |
|
||||||
|
| Existing dashboards are hard-coded | Extract only needed query/render fragments into new panel renderers |
|
||||||
|
| Panel config becomes unsafe JSON | Decode into Haskell ADTs and validate before querying |
|
||||||
|
| Role-aware defaults require missing schema | Use neutral default first; only best-effort stewardship hints |
|
||||||
|
| AutoRefresh refreshes too much | Bound all queries; defer per-panel refresh unless needed |
|
||||||
|
| Panel annotations lack stable identity | Require `dashboard_panels.widget_id` and `widgetEnvelope` |
|
||||||
|
| COUNT decode errors recur | Cast aggregate counts or decode as `Int64` in implementation |
|
||||||
|
|
||||||
|
## 11. Open Questions for FDD
|
||||||
|
|
||||||
|
1. Should table names use `personal_dashboards`/`dashboard_panels`, or a more
|
||||||
|
explicit `user_dashboards` prefix?
|
||||||
|
2. Should removed panels archive their linked widgets or mark them deprecated?
|
||||||
|
3. Should panel widgets be owned by the framework hub, or by the source hub when
|
||||||
|
a panel is hub-specific?
|
||||||
|
4. Should the first implementation allow multiple dashboards per user, or only
|
||||||
|
one default dashboard with schema ready for multiples?
|
||||||
|
5. Should `autoRefresh` wrap the whole dashboard action initially, or should
|
||||||
|
live panel fragments get their own actions?
|
||||||
|
6. Should watched hubs be a separate table in the first slice, or represented
|
||||||
|
as dashboard panel config only?
|
||||||
|
|
||||||
|
## 12. Recommendation
|
||||||
|
|
||||||
|
Proceed to FDD with a small, governed, server-rendered personal dashboard:
|
||||||
|
|
||||||
|
- One default dashboard per user, schema ready for multiples.
|
||||||
|
- Six first-slice panel types.
|
||||||
|
- `dashboard_panels.widget_id` as the governance anchor.
|
||||||
|
- Existing `panel` widget type for saved panel widgets.
|
||||||
|
- Whole-page `autoRefresh` initially, with bounded queries.
|
||||||
|
- Simple edit forms and no custom client runtime.
|
||||||
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?
|
||||||
348
docs/research/personal-dashboard-current-state.md
Normal file
348
docs/research/personal-dashboard-current-state.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# Personal Dashboard Current-State Research
|
||||||
|
|
||||||
|
**Workplan:** IHUB-WP-0020
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Status:** Research deliverable for T01
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This note reviews the current inter-hub implementation before designing a
|
||||||
|
personal dashboard framework. The main finding is that inter-hub already has a
|
||||||
|
large set of server-rendered dashboard surfaces, governed widget identity, type
|
||||||
|
registries, annotations, event capture, hub health, API usage, marketplace, and
|
||||||
|
learning data. The personal dashboard should compose these capabilities instead
|
||||||
|
of inventing a separate dashboard product.
|
||||||
|
|
||||||
|
External dashboard products are used here only for pattern extraction. The
|
||||||
|
implementation direction remains IHP, HSX, Tailwind, server-rendered forms, and
|
||||||
|
existing IHF governance primitives.
|
||||||
|
|
||||||
|
## Evidence Reviewed
|
||||||
|
|
||||||
|
Repo files inspected for this note:
|
||||||
|
|
||||||
|
- `Web/Controller/Hubs.hs`
|
||||||
|
- `Web/Controller/Sessions.hs`
|
||||||
|
- `Web/FrontController.hs`
|
||||||
|
- `Web/Routes.hs`
|
||||||
|
- `Web/Types.hs`
|
||||||
|
- `Application/Schema.sql`
|
||||||
|
- `Application/Helper/View.hs`
|
||||||
|
- `Web/Controller/FederatedGovernance.hs`
|
||||||
|
- `Web/Controller/FederatedPolicyOverlays.hs`
|
||||||
|
- `Web/Controller/ApiDashboard.hs`
|
||||||
|
- `Web/Controller/MarketplaceDashboard.hs`
|
||||||
|
- `Web/Controller/LearningDashboard.hs`
|
||||||
|
- `docs/phase1-summary.md` through `docs/phase8-summary.md`
|
||||||
|
- `docs/ihp-ihf-mapping.md`
|
||||||
|
- `docs/widget-envelope-convention.md`
|
||||||
|
- Workplans IHUB-WP-0001 through IHUB-WP-0015 where dashboard scope was
|
||||||
|
introduced.
|
||||||
|
|
||||||
|
## Current Authenticated Entry Point
|
||||||
|
|
||||||
|
The public root and documentation pages already exist through IHUB-WP-0015 and
|
||||||
|
are registered last in `Web.FrontController`. The authenticated login flow still
|
||||||
|
redirects to `HubsAction`:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
login user
|
||||||
|
redirectTo HubsAction
|
||||||
|
```
|
||||||
|
|
||||||
|
`HubsAction` renders a table of hubs. It is useful as an admin list, but it is
|
||||||
|
not a personal daily operating surface.
|
||||||
|
|
||||||
|
The sidebar already links to several platform surfaces, including Hubs,
|
||||||
|
Learning, Ops Review, Federation, API Dashboard, Hub Registry, and Marketplace.
|
||||||
|
The personal dashboard should therefore become a new authenticated landing
|
||||||
|
route and a sidebar entry, while public root behavior remains unchanged.
|
||||||
|
|
||||||
|
## Existing Dashboard Inventory
|
||||||
|
|
||||||
|
| Surface | Action | Scope | Live? | Reuse potential |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Hub list | `HubsAction` | Global hub table | No | Source for watched hubs and hub selector |
|
||||||
|
| Hub show | `ShowHubAction` | One hub | Yes | Recent events, annotations, widgets, manifest summary |
|
||||||
|
| Triage dashboard | `TriageDashboardAction` | One hub | Yes | Open candidates, recent escalations, annotation breakdown |
|
||||||
|
| Governance dashboard | `GovernanceDashboardAction` | One hub | Yes | Accepted candidates, requirements, decisions, traceability |
|
||||||
|
| Antifragility dashboard | `AntifragilityDashboardAction` | One hub | Yes | Deployments, outcome signals, recurrence leaderboard |
|
||||||
|
| Agent audit dashboard | `AgentAuditDashboardAction` | One hub context, global data | Yes | Agent proposals and review status |
|
||||||
|
| Adapter compatibility | `AdapterCompatibilityDashboardAction` | One hub | Yes | Adapter and contract compatibility panels |
|
||||||
|
| Friction heatmap | `FrictionHeatmapAction` | One hub | Yes | Widget friction summary |
|
||||||
|
| Bottleneck dashboard | `BottleneckDashboardAction` | One hub | Yes | Open bottlenecks |
|
||||||
|
| Hub health history | `HubHealthHistoryAction` | One hub | Yes | Health snapshot trend |
|
||||||
|
| Operational review board | `OperationalReviewBoardAction` | Global | Yes | Hub health, top friction, bottlenecks, propagations |
|
||||||
|
| Federated governance | `FederatedGovernanceDashboardAction` | Global | Yes | Ownership, routing, policy, stewardship, archive activity |
|
||||||
|
| Policy compliance | `PolicyComplianceDashboardAction` | Global | Yes | Active overlays and policy reference coverage |
|
||||||
|
| API dashboard | `ShowApiDashboardAction` | Global API consumers | Yes | Per-consumer request volume, error rate, last seen |
|
||||||
|
| Marketplace | `MarketplaceDashboardAction` | Global patterns/templates | Yes | Trending patterns, search/filter catalogue |
|
||||||
|
| Learning dashboard | `LearningDashboardAction` | Global learning memory | Yes | Insights, knowledge highlights, pattern rankings |
|
||||||
|
|
||||||
|
The reusable unit today is not a standalone panel renderer. It is a controller
|
||||||
|
query plus an HSX view fragment. WP-0021 should extract only the first small set
|
||||||
|
of renderers needed for the personal dashboard. A broad refactor of existing
|
||||||
|
dashboards is explicitly unnecessary for the first slice.
|
||||||
|
|
||||||
|
## AutoRefresh and Query Patterns
|
||||||
|
|
||||||
|
Most dashboard actions wrap the whole action with `autoRefresh do`. This is
|
||||||
|
simple and consistent with the existing IHP style. The current app does not have
|
||||||
|
a reusable per-panel refresh abstraction.
|
||||||
|
|
||||||
|
Useful bounded patterns already exist:
|
||||||
|
|
||||||
|
- `ShowHubAction` limits recent interaction events to 50 and annotations to 20.
|
||||||
|
- `GovernanceDashboardAction` limits recent decisions to 20.
|
||||||
|
- `LearningDashboardAction` limits correlations, rankings, insights, and
|
||||||
|
knowledge highlights.
|
||||||
|
- `MarketplaceDashboardAction` limits published patterns/templates and casts the
|
||||||
|
trending adoption count to integer.
|
||||||
|
|
||||||
|
Risky or broad patterns to avoid copying directly:
|
||||||
|
|
||||||
|
- Some hub dashboards fetch all related records for a hub and filter in memory.
|
||||||
|
That is acceptable for small scoped screens, but a personal dashboard should
|
||||||
|
bound every panel query by hub, time, status, and limit.
|
||||||
|
- Several global dashboards fetch all hubs or all decision records. A personal
|
||||||
|
view should either limit these or explicitly display only summarized rows.
|
||||||
|
- Raw `COUNT(*)` queries should cast to `integer` or decode as `Int64`. Recent
|
||||||
|
production work exposed PostgreSQL/Haskell decode failures when `COUNT(*)`
|
||||||
|
was decoded as `Int`.
|
||||||
|
|
||||||
|
Recommendation: first implementation can wrap the whole personal dashboard
|
||||||
|
action in `autoRefresh do`. The FDD should leave finer-grained panel refresh as
|
||||||
|
a later optimization unless a simple route-level fragment pattern emerges.
|
||||||
|
|
||||||
|
## Governed Widget and Annotation Constraints
|
||||||
|
|
||||||
|
`Application.Helper.View.widgetEnvelope` wraps a `Widget` record and injects
|
||||||
|
governance metadata:
|
||||||
|
|
||||||
|
- `data-widget-id`
|
||||||
|
- `data-widget-type`
|
||||||
|
- `data-hub-id`
|
||||||
|
- `data-capability-ref`
|
||||||
|
- `data-view-context`
|
||||||
|
- `data-policy-scope`
|
||||||
|
- `data-widget-version`
|
||||||
|
|
||||||
|
It also renders an Annotate link to the widget annotation view. The helper
|
||||||
|
warns when `view_context` is absent. Therefore, a saved personal dashboard panel
|
||||||
|
must not be treated as transient markup. It needs stable widget identity.
|
||||||
|
|
||||||
|
The registry seed includes a framework-level widget type named `panel`. That is
|
||||||
|
the best first choice for saved dashboard panels. A saved panel instance should
|
||||||
|
create or reference a `widgets` row with:
|
||||||
|
|
||||||
|
- `widget_type = 'panel'`
|
||||||
|
- `capability_ref = 'personal-dashboard.<panel-key>'`
|
||||||
|
- `view_context = 'personal-dashboard/<panel-key>'`
|
||||||
|
- `policy_scope = 'internal'`
|
||||||
|
- `status = 'active'`
|
||||||
|
|
||||||
|
The FDD should decide the owning hub rule. Recommended first slice: use the
|
||||||
|
framework hub for personal dashboard panel widgets and store source hub filters
|
||||||
|
in panel config. This keeps the personal dashboard itself governed by inter-hub
|
||||||
|
while still letting panels point at hub-specific data.
|
||||||
|
|
||||||
|
## Schema Available for First-Slice Panels
|
||||||
|
|
||||||
|
Existing tables with direct panel value:
|
||||||
|
|
||||||
|
- `hubs`
|
||||||
|
- `widgets`
|
||||||
|
- `widget_versions`
|
||||||
|
- `interaction_events`
|
||||||
|
- `annotations`
|
||||||
|
- `annotation_threads`
|
||||||
|
- `requirement_candidates`
|
||||||
|
- `triage_states`
|
||||||
|
- `reviewer_assignments`
|
||||||
|
- `requirements`
|
||||||
|
- `decision_records`
|
||||||
|
- `deployment_records`
|
||||||
|
- `outcome_signals`
|
||||||
|
- `friction_scores`
|
||||||
|
- `bottleneck_records`
|
||||||
|
- `hub_health_snapshots`
|
||||||
|
- `cross_hub_propagations`
|
||||||
|
- `widget_ownerships`
|
||||||
|
- `hub_routing_rules`
|
||||||
|
- `federated_policy_overlays`
|
||||||
|
- `stewardship_roles`
|
||||||
|
- `archive_records`
|
||||||
|
- `widget_type_registry`
|
||||||
|
- `event_type_registry`
|
||||||
|
- `annotation_category_registry`
|
||||||
|
- `policy_scope_registry`
|
||||||
|
- `hub_capability_manifests`
|
||||||
|
- `api_consumers`
|
||||||
|
- `api_request_log`
|
||||||
|
- `widget_patterns`
|
||||||
|
- `pattern_adoptions`
|
||||||
|
- `governance_templates`
|
||||||
|
- `governance_template_clones`
|
||||||
|
- `outcome_correlations`
|
||||||
|
- `pattern_performance_records`
|
||||||
|
- `adaptive_threshold_configs`
|
||||||
|
- `institutional_knowledge_entries`
|
||||||
|
- `learning_insights`
|
||||||
|
|
||||||
|
Important gaps:
|
||||||
|
|
||||||
|
- No `personal_dashboards` table.
|
||||||
|
- No `dashboard_panel_types` table.
|
||||||
|
- No `dashboard_panels` table.
|
||||||
|
- No saved watched-hub set or user preference table.
|
||||||
|
- No user role column.
|
||||||
|
- No panel config decoder/validator.
|
||||||
|
- No dedicated panel renderer module.
|
||||||
|
- No explicit default dashboard seeding helper.
|
||||||
|
|
||||||
|
## User and Role Model Findings
|
||||||
|
|
||||||
|
The `users` table has email, password hash, name, lockout fields, and created
|
||||||
|
time. It has no role. `stewardship_roles` stores `assigned_to` as text and is
|
||||||
|
hub-scoped. That can help infer operator relevance, but it is not a reliable
|
||||||
|
role foreign key.
|
||||||
|
|
||||||
|
Recommendation: do not add `users.role` for the first slice. Seed a neutral
|
||||||
|
default dashboard for all authenticated users, then allow the user to edit panel
|
||||||
|
layout and filters. If a default needs hub relevance, match active
|
||||||
|
`stewardship_roles.assigned_to` against user email or name as a best-effort
|
||||||
|
hint, not as an authorization rule.
|
||||||
|
|
||||||
|
## First-Slice Panel Candidates
|
||||||
|
|
||||||
|
The following panels are practical without broad refactoring:
|
||||||
|
|
||||||
|
| Panel key | Source | Default filter | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `watched-hubs` | `hubs`, latest `hub_health_snapshots` | all hubs, limit 12 | First panel can be neutral until watched hubs exist |
|
||||||
|
| `recent-interactions` | `interaction_events`, `widgets`, `hubs` | last 24h, limit 25 | Existing indexes support recent ordering |
|
||||||
|
| `triage-queue` | `requirement_candidates` | `status = 'open'`, limit 10 | Can join source widget/hub for context |
|
||||||
|
| `recent-decisions` | `decision_records` | last 30 days, limit 10 | Good governance reviewer entry point |
|
||||||
|
| `hub-health` | `hub_health_snapshots`, `bottleneck_records` | latest per hub, limit 12 | Needs bounded latest-per-hub query |
|
||||||
|
| `learning-digest` | `learning_insights`, `institutional_knowledge_entries` | latest, limit 5/5 | Already bounded in existing dashboard |
|
||||||
|
|
||||||
|
Panels to defer until after the framework is proven:
|
||||||
|
|
||||||
|
- `agent-proposals`
|
||||||
|
- `api-usage`
|
||||||
|
- `marketplace-trending`
|
||||||
|
- `my-annotations`
|
||||||
|
- `adapter-compatibility`
|
||||||
|
- `policy-compliance`
|
||||||
|
|
||||||
|
The deferred panels are valuable, but they are not needed to prove dashboard
|
||||||
|
persistence, layout, panel renderer dispatch, and governed panel identity.
|
||||||
|
|
||||||
|
## External Pattern Extraction
|
||||||
|
|
||||||
|
| System | Useful pattern | Translation for inter-hub |
|
||||||
|
|---|---|---|
|
||||||
|
| Grafana | Dashboard as saved grid of panels with variables and refresh behavior | Save panel rows plus hub/time filters; keep server-rendered refresh |
|
||||||
|
| Kibana dashboards | Saved searches and time range awareness | Treat panel query config as explicit, bounded, validated config |
|
||||||
|
| Retool/Appsmith | Widget catalogue and data binding | Use a server-side panel catalogue; avoid client runtime/data binding |
|
||||||
|
| Linear home | Personal "my work" aggregation across entities | Make the personal dashboard a daily work queue, not a clone of every dashboard |
|
||||||
|
| Notion linked databases | Multiple saved views over the same records | Let panels define filter/sort/display options against existing tables |
|
||||||
|
| Metabase | Question as a governed reusable unit | Treat panel renderer plus validated config as the reusable unit |
|
||||||
|
| Streamlit | Simple declarative layout vocabulary | Use predictable grid rows/spans and forms rather than drag-and-drop |
|
||||||
|
|
||||||
|
The key pattern across these systems is not visual complexity. It is that a
|
||||||
|
dashboard is a saved composition of bounded questions/panels with explicit
|
||||||
|
parameters. For inter-hub, those questions must remain governed IHF widgets.
|
||||||
|
|
||||||
|
## Answers to WP-0020 Research Questions
|
||||||
|
|
||||||
|
### Which existing fragments can become first-slice renderers?
|
||||||
|
|
||||||
|
Good first-slice candidates:
|
||||||
|
|
||||||
|
- Recent activity from `ShowHubAction`.
|
||||||
|
- Open candidate queue from `TriageDashboardAction`.
|
||||||
|
- Recent decisions from `GovernanceDashboardAction`.
|
||||||
|
- Latest hub health from `OperationalReviewBoardAction` and
|
||||||
|
`HubHealthHistoryAction`.
|
||||||
|
- Learning digest from `LearningDashboardAction`.
|
||||||
|
- Watched hubs from `HubsAction` plus latest health snapshots.
|
||||||
|
|
||||||
|
These can be implemented as new renderer functions that reuse the same model
|
||||||
|
queries and link to existing source dashboards for detail.
|
||||||
|
|
||||||
|
### Which configs are needed on day one?
|
||||||
|
|
||||||
|
Recommended day-one config options:
|
||||||
|
|
||||||
|
- `hubIds :: [Id Hub]` or `hubFilter :: Maybe [Id Hub]`
|
||||||
|
- `timeRange :: Last24Hours | Last7Days | Last30Days | AllTimeBounded`
|
||||||
|
- `limit :: Int`
|
||||||
|
- `sort :: NewestFirst | OldestFirst | HighestRiskFirst`
|
||||||
|
- `displayMode :: Compact | Detailed`
|
||||||
|
|
||||||
|
Config should be stored as JSONB but decoded into a Haskell ADT before use.
|
||||||
|
Invalid config should fall back to panel defaults and surface a non-fatal
|
||||||
|
operator warning.
|
||||||
|
|
||||||
|
### Which panels should live-refresh?
|
||||||
|
|
||||||
|
Live-refresh in the first slice:
|
||||||
|
|
||||||
|
- `recent-interactions`
|
||||||
|
- `triage-queue`
|
||||||
|
- `hub-health`
|
||||||
|
- `learning-digest`
|
||||||
|
|
||||||
|
Static per request in the first slice:
|
||||||
|
|
||||||
|
- `watched-hubs`
|
||||||
|
- `recent-decisions`
|
||||||
|
|
||||||
|
If the first implementation wraps the entire personal dashboard in
|
||||||
|
`autoRefresh`, all panels will refresh together. That is acceptable initially if
|
||||||
|
queries are bounded.
|
||||||
|
|
||||||
|
### How should saved panels map to governed widgets?
|
||||||
|
|
||||||
|
Each saved dashboard panel should own a `widgets` row and store the id on
|
||||||
|
`dashboard_panels.widget_id`. The panel renderer should call `widgetEnvelope`
|
||||||
|
with that widget. This gives stable annotation and interaction capture identity.
|
||||||
|
|
||||||
|
Panel lifecycle:
|
||||||
|
|
||||||
|
1. User adds a panel.
|
||||||
|
2. Controller creates `dashboard_panels` row.
|
||||||
|
3. Controller creates linked `widgets` row with `widget_type = 'panel'`.
|
||||||
|
4. Controller creates a `widget_versions` snapshot for the panel widget.
|
||||||
|
5. Show view renders the panel through `widgetEnvelope`.
|
||||||
|
6. Removing a panel should mark the widget archived or deprecated, not delete
|
||||||
|
interaction history.
|
||||||
|
|
||||||
|
### What should be deferred?
|
||||||
|
|
||||||
|
Defer:
|
||||||
|
|
||||||
|
- Drag-and-drop layout.
|
||||||
|
- Shared dashboards.
|
||||||
|
- Team dashboards.
|
||||||
|
- External datasource connectors.
|
||||||
|
- Client-side data fetching.
|
||||||
|
- Per-panel WebSocket channels.
|
||||||
|
- Full refactor of existing dashboard views.
|
||||||
|
- Complex role model.
|
||||||
|
- Dashboard marketplace/templates beyond one seeded default.
|
||||||
|
|
||||||
|
## Recommendations for T02/T03
|
||||||
|
|
||||||
|
1. Define the personal dashboard as the authenticated landing page, not a
|
||||||
|
replacement for existing source dashboards.
|
||||||
|
2. Use a small panel catalogue for the first implementation.
|
||||||
|
3. Persist dashboard/panel rows in relational tables and panel config in JSONB.
|
||||||
|
4. Decode panel config into explicit Haskell ADTs before querying.
|
||||||
|
5. Give every saved panel stable `Widget` identity.
|
||||||
|
6. Use the existing `panel` widget type.
|
||||||
|
7. Keep the default dashboard neutral and editable.
|
||||||
|
8. Bound every panel query.
|
||||||
|
9. Cast SQL aggregate counts to integer when decoding as `Int`.
|
||||||
|
10. Keep implementation tasks small enough to avoid a cross-dashboard refactor.
|
||||||
91
flake.nix
91
flake.nix
@@ -18,7 +18,7 @@
|
|||||||
systems = import systems;
|
systems = import systems;
|
||||||
imports = [ ihp.flakeModules.default ];
|
imports = [ ihp.flakeModules.default ];
|
||||||
|
|
||||||
perSystem = { pkgs, ... }: {
|
perSystem = { pkgs, config, lib, ... }: {
|
||||||
ihp = {
|
ihp = {
|
||||||
appName = "inter-hub";
|
appName = "inter-hub";
|
||||||
enable = true;
|
enable = true;
|
||||||
@@ -77,6 +77,12 @@
|
|||||||
# static.makeBundling = true; # Set false if not using Makefile for CSS/JS bundling
|
# 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`
|
# Custom configuration that will start with `devenv up`
|
||||||
devenv.shells.default = {
|
devenv.shells.default = {
|
||||||
# Start Mailhog on local development to catch outgoing emails
|
# Start Mailhog on local development to catch outgoing emails
|
||||||
@@ -85,6 +91,89 @@
|
|||||||
# PostgreSQL extensions
|
# PostgreSQL extensions
|
||||||
# services.postgres.extensions = extensions: [ extensions.postgis ];
|
# 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).
|
# Resource limits for constrained host (2 CPU, ~3.8 GiB RAM).
|
||||||
# -A32m: smaller minor heap (reduces GC pressure).
|
# -A32m: smaller minor heap (reduces GC pressure).
|
||||||
# -M2g: hard heap ceiling (prevents OOM on large compiles).
|
# -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.
|
||||||
109
workplans/ADHOC-2026-06-15.md
Normal file
109
workplans/ADHOC-2026-06-15.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
id: ADHOC-2026-06-15
|
||||||
|
type: workplan
|
||||||
|
title: "Ad hoc Inter-Hub production fixes"
|
||||||
|
domain: custodian
|
||||||
|
repo: inter-hub
|
||||||
|
status: blocked
|
||||||
|
owner: codex
|
||||||
|
created: "2026-06-15"
|
||||||
|
updated: "2026-06-16"
|
||||||
|
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: blocked
|
||||||
|
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.
|
||||||
|
|
||||||
|
Recheck on 2026-06-16:
|
||||||
|
|
||||||
|
- The local source fix is still present:
|
||||||
|
`Application/Helper/TypeRegistry.hs` casts registry validation counts with
|
||||||
|
`COUNT(*)::int`, and `Application/Helper/ApiRateLimit.hs` casts API request
|
||||||
|
log counts with `COUNT(*)::int`.
|
||||||
|
- A source-wide `COUNT` search found the targeted v2 bootstrap helpers fixed.
|
||||||
|
Other raw aggregate counts remain in non-bootstrap dashboard/marketplace/API
|
||||||
|
surfaces and are outside this ad hoc task's acceptance path unless they are
|
||||||
|
separately reproduced as decode failures.
|
||||||
|
- Live public `GET https://hub.coulomb.social/api/v2/hubs` returns `200` and
|
||||||
|
lists `ops-hub`, confirming the public API and ops-hub route surface are
|
||||||
|
present.
|
||||||
|
- Live unauthenticated `GET /api/v2/widgets` and `GET /api/v2/hub-registry`
|
||||||
|
return `401`, confirming the protected routes exist and authentication is
|
||||||
|
enforced before the code path that previously failed.
|
||||||
|
- Unauthenticated registry manifest checks for tags `68c66b9` and `5101eb5`
|
||||||
|
now return `401`, not the earlier unauthenticated `manifest unknown`; this
|
||||||
|
session cannot prove image publication from the public registry endpoint.
|
||||||
|
- The previously documented local temp key
|
||||||
|
`/tmp/ops-hub-runtime-key-gb5nxg92` is absent. No approved runtime key or
|
||||||
|
operator key is available in this session, so the protected widget-create and
|
||||||
|
hub-registry smoke checks could not be run without a secret handoff.
|
||||||
|
|
||||||
|
Current blocked reason: source-side work appears complete, but production
|
||||||
|
closure still requires one of:
|
||||||
|
|
||||||
|
1. an attended operator/runtime key handoff so Codex can run the protected
|
||||||
|
smoke without printing the key;
|
||||||
|
2. operator-provided non-secret evidence that production is running an image
|
||||||
|
containing commit `5101eb5` or an equivalent COUNT decode fix; or
|
||||||
|
3. operator-run smoke evidence showing authenticated `POST /api/v2/widgets`
|
||||||
|
and authenticated `GET /api/v2/hub-registry` succeed against production.
|
||||||
|
|
||||||
|
Until one of those exists, this ad hoc workplan should remain `blocked`, not
|
||||||
|
`done`.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0001
|
id: IHUB-WP-0001
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 1 — Minimal Interaction Core"
|
title: "IHF Phase 1 — Minimal Interaction Core"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0002
|
id: IHUB-WP-0002
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 2 — Structured Feedback and Triage"
|
title: "IHF Phase 2 — Structured Feedback and Triage"
|
||||||
domain: custodian
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0003
|
id: IHUB-WP-0003
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 3 — Governance and Decision Linkage"
|
title: "IHF Phase 3 — Governance and Decision Linkage"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0004
|
id: IHUB-WP-0004
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 4 — Outcome Observation and Antifragility Loop"
|
title: "IHF Phase 4 — Outcome Observation and Antifragility Loop"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0005
|
id: IHUB-WP-0005
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 5 — Agent-Assisted Distillation and Suggestion"
|
title: "IHF Phase 5 — Agent-Assisted Distillation and Suggestion"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0006
|
id: IHUB-WP-0006
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 6 — Cross-Framework UI Adaptation Layer"
|
title: "IHF Phase 6 — Cross-Framework UI Adaptation Layer"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0007
|
id: IHUB-WP-0007
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 7 — Advanced Observability and Operational Integration"
|
title: "IHF Phase 7 — Advanced Observability and Operational Integration"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0008
|
id: IHUB-WP-0008
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 8 — Federated Hub Maturity"
|
title: "IHF Phase 8 — Federated Hub Maturity"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0009
|
id: IHUB-WP-0009
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF GAAF Compliance Foundation — Type Registries, Extension Manifests, and Architectural Contracts"
|
title: "IHF GAAF Compliance Foundation — Type Registries, Extension Manifests, and Architectural Contracts"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
id: IHUB-WP-0010
|
id: IHUB-WP-0010
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 9 — External API Surface and Consumer SDKs"
|
title: "IHF Phase 9 — External API Surface and Consumer SDKs"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: active
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
topic_slug: inter_hub
|
topic_slug: inter_hub
|
||||||
created: "2026-04-01"
|
created: "2026-04-01"
|
||||||
updated: "2026-04-01"
|
updated: "2026-06-07"
|
||||||
|
completed: "2026-06-07"
|
||||||
state_hub_sync: done
|
state_hub_sync: done
|
||||||
state_hub_workstream_id: "c6c6e87f-e145-4bc4-9881-61f92b14d4de"
|
state_hub_workstream_id: "c6c6e87f-e145-4bc4-9881-61f92b14d4de"
|
||||||
---
|
---
|
||||||
@@ -68,6 +69,12 @@ Schema additions:
|
|||||||
- `webhook_deliveries` table
|
- `webhook_deliveries` table
|
||||||
- `api_request_log` table (for usage dashboard and rate limiting)
|
- `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
|
## 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):**
|
**Exit criteria (Phase 9 complete when all of these are true):**
|
||||||
|
|
||||||
- [ ] All core IHF artifact types are readable via `/api/v2/`
|
- [x] All core IHF artifact types are readable via `/api/v2/`
|
||||||
- [ ] Interaction events and annotations are writable via `/api/v2/`
|
- [x] Interaction events and annotations are writable via `/api/v2/`
|
||||||
- [ ] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
|
- [x] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
|
||||||
`enum` arrays from live registries
|
`enum` arrays from live registries
|
||||||
- [ ] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
|
- [x] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
|
||||||
- [ ] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
|
- [x] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
|
||||||
- [ ] Webhook delivery confirmed for `interaction_event.created` and
|
- [x] Webhook delivery confirmed for `interaction_event.created` and
|
||||||
`requirement_candidate.created`
|
`requirement_candidate.created`
|
||||||
- [ ] API usage dashboard renders correctly with AutoRefresh
|
- [x] API usage dashboard renders correctly with AutoRefresh
|
||||||
- [ ] OAuth client credentials flow works end-to-end
|
- [x] OAuth client credentials flow works end-to-end
|
||||||
- [ ] Submission of an unregistered `event_type` returns HTTP 422 with
|
- [x] Submission of an unregistered `event_type` returns HTTP 422 with
|
||||||
registry-referenced error
|
registry-referenced error
|
||||||
- [ ] Rate limiting returns 429 with `Retry-After`
|
- [x] Rate limiting returns 429 with `Retry-After`
|
||||||
- [ ] CLAUDE.md updated; IHUB-WP-0010 listed as complete
|
- [x] CLAUDE.md updated; IHUB-WP-0010 listed as complete
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0011
|
id: IHUB-WP-0011
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 10 — Hub Registry and Widget Marketplace"
|
title: "IHF Phase 10 — Hub Registry and Widget Marketplace"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0012
|
id: IHUB-WP-0012
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 11 — Advanced AI Federation"
|
title: "IHF Phase 11 — Advanced AI Federation"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0013
|
id: IHUB-WP-0013
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "IHF Phase 12 — Platform Memory and Continuous Learning"
|
title: "IHF Phase 12 — Platform Memory and Continuous Learning"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0014
|
id: IHUB-WP-0014
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Pre-flight: Close Deployment Gaps"
|
title: "Pre-flight: Close Deployment Gaps"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0015
|
id: IHUB-WP-0015
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Local Deployment — Intro and Tutorial Web UI"
|
title: "Local Deployment — Intro and Tutorial Web UI"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
@@ -11,8 +11,7 @@ created: "2026-04-03"
|
|||||||
updated: "2026-04-03"
|
updated: "2026-04-03"
|
||||||
state_hub_sync: done
|
state_hub_sync: done
|
||||||
state_hub_workstream_id: "946d50b8-441c-4c0a-b1a0-2a4fb3340d16"
|
state_hub_workstream_id: "946d50b8-441c-4c0a-b1a0-2a4fb3340d16"
|
||||||
depends_on: IHUB-WP-0014
|
depends_on: IHUB-WP-0014---
|
||||||
---
|
|
||||||
|
|
||||||
# IHUB-WP-0015 — Local Deployment: Intro and Tutorial Web UI
|
# IHUB-WP-0015 — Local Deployment: Intro and Tutorial Web UI
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0016
|
id: IHUB-WP-0016
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Build Infrastructure: Incremental Compilation and Autonomous Error-Fix Loop"
|
title: "Build Infrastructure: Incremental Compilation and Autonomous Error-Fix Loop"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: IHUB-WP-0017
|
id: IHUB-WP-0017
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Autonomous Error-Fix Loop: Reach Clean Build"
|
title: "Autonomous Error-Fix Loop: Reach Clean Build"
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: done
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
|
|||||||
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: infotech
|
||||||
|
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: infotech
|
||||||
|
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`.
|
||||||
415
workplans/IHUB-WP-0020-personal-dashboard-framework.md
Normal file
415
workplans/IHUB-WP-0020-personal-dashboard-framework.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
---
|
||||||
|
id: IHUB-WP-0020
|
||||||
|
type: workplan
|
||||||
|
title: "Personal Dashboard Framework"
|
||||||
|
domain: infotech
|
||||||
|
repo: inter-hub
|
||||||
|
status: finished
|
||||||
|
owner: tegwick
|
||||||
|
topic_slug: inter_hub
|
||||||
|
created: "2026-05-03"
|
||||||
|
updated: "2026-06-16"
|
||||||
|
phase: 13
|
||||||
|
state_hub_workstream_id: "72fc022b-0196-492a-aaba-3475f8768f06"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Personal Dashboard Framework
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Design the first personal dashboard layer for inter-hub: an authenticated,
|
||||||
|
per-user landing surface that composes the most important existing hub,
|
||||||
|
governance, API, marketplace, and learning signals into a configurable daily
|
||||||
|
operator view.
|
||||||
|
|
||||||
|
This workplan is now a design and implementation-planning workplan. It should
|
||||||
|
produce the current-state audit, product requirements, functional design, and
|
||||||
|
follow-on implementation workplan needed to build the feature safely.
|
||||||
|
|
||||||
|
## Review Update: 2026-06-15
|
||||||
|
|
||||||
|
This workplan was reviewed against the current repository state and updated
|
||||||
|
from `backlog` to `ready`.
|
||||||
|
|
||||||
|
The original version assumed inter-hub mainly had a raw Hubs list and needed a
|
||||||
|
greenfield dashboard framework. That assumption is outdated. The repo now has
|
||||||
|
many dashboard-like surfaces and governed interaction primitives that should be
|
||||||
|
reused instead of bypassed:
|
||||||
|
|
||||||
|
- Public root/intro pages exist from IHUB-WP-0015; the authenticated login flow
|
||||||
|
still redirects to `HubsAction`.
|
||||||
|
- Hub-level dashboard actions already exist in `Web.Controller.Hubs`, including
|
||||||
|
hub show, triage, governance, antifragility, agent audit, adapter
|
||||||
|
compatibility, friction heatmap, bottleneck, hub health history, and the
|
||||||
|
operational review board.
|
||||||
|
- Cross-hub and platform dashboards already exist: federated governance, policy
|
||||||
|
compliance, API usage, marketplace, and learning dashboard.
|
||||||
|
- The governed interaction substrate is mature: `widgets`, `widget_versions`,
|
||||||
|
`interaction_events`, `annotations`, type registries, hub manifests,
|
||||||
|
ownership/routing, API request logs, hub health snapshots, learning insights,
|
||||||
|
and institutional knowledge are all present.
|
||||||
|
- There is no personal dashboard schema, controller, saved panel catalogue,
|
||||||
|
user preference model, or role-aware default layout yet.
|
||||||
|
- Existing dashboards are mostly hard-coded controller queries plus HSX view
|
||||||
|
fragments. They are useful source material, but they are not yet reusable
|
||||||
|
panel renderers.
|
||||||
|
- The `users` table has no role column. `stewardship_roles.assigned_to` is text
|
||||||
|
and hub-scoped, so role-aware defaults must be designed carefully instead of
|
||||||
|
assuming a user-role foreign key exists.
|
||||||
|
|
||||||
|
The updated scope is therefore integration-first: define a personal dashboard
|
||||||
|
contract that reuses existing data sources and view patterns, then introduce a
|
||||||
|
small panel renderer abstraction only where it removes real duplication.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Authenticated personal dashboard route and post-login redirect design.
|
||||||
|
- Per-user saved dashboard record with ordered panel instances.
|
||||||
|
- A server-rendered panel catalogue backed by existing inter-hub models.
|
||||||
|
- Simple layout editing through IHP forms; no drag-and-drop in the first slice.
|
||||||
|
- Hub/time filters for panels where the underlying queries already support
|
||||||
|
bounded data.
|
||||||
|
- Panel-level governance: each rendered saved panel must be annotatable and
|
||||||
|
event-capturable through the existing `widgetEnvelope` convention.
|
||||||
|
- A migration path that reuses current dashboard queries before attempting broad
|
||||||
|
refactors.
|
||||||
|
|
||||||
|
### Out of Scope for the First Implementation Workplan
|
||||||
|
|
||||||
|
- Client-side dashboard frameworks or client-side data fetching.
|
||||||
|
- External datasource connectors.
|
||||||
|
- Shared/team dashboards.
|
||||||
|
- Mobile-native layout editing.
|
||||||
|
- Drag-and-drop layout editing.
|
||||||
|
- A general purpose report builder.
|
||||||
|
- Rewriting every existing dashboard into panel renderers.
|
||||||
|
|
||||||
|
## Current Design Constraints
|
||||||
|
|
||||||
|
- Server-rendered IHP views remain the default. `autoRefresh` is acceptable for
|
||||||
|
panels that already use live refresh patterns.
|
||||||
|
- Tailwind and existing HSX view conventions should be reused.
|
||||||
|
- Runtime panel config may be stored as JSONB, but renderer code should decode
|
||||||
|
into explicit Haskell config types before use.
|
||||||
|
- Do not create an ungoverned visual component layer. A saved dashboard panel
|
||||||
|
must either reference or create a `Widget` row, most likely using the existing
|
||||||
|
framework-level `panel` widget type, so annotations and interaction events
|
||||||
|
remain first-class IHF artifacts.
|
||||||
|
- Avoid adding a `users.role` column unless the PRS/FDD proves it is needed.
|
||||||
|
Prefer defaults derived from current user identity, stewardship assignments,
|
||||||
|
selected watched hubs, or explicit dashboard template choice.
|
||||||
|
|
||||||
|
## Proposed First-Slice Panel Catalogue
|
||||||
|
|
||||||
|
The initial catalogue should be limited to panels that can be built from
|
||||||
|
existing tables and controllers:
|
||||||
|
|
||||||
|
| Panel key | Label | Source surface/data | Live? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `watched-hubs` | Watched Hubs | `hubs`, `hub_health_snapshots`, optional saved hub filter | No |
|
||||||
|
| `recent-interactions` | Recent Activity | `interaction_events` plus `widgets` and `hubs` | Yes |
|
||||||
|
| `triage-queue` | Triage Queue | open `requirement_candidates` | Yes |
|
||||||
|
| `recent-decisions` | Recent Decisions | `decision_records`, requirements, candidates | No |
|
||||||
|
| `hub-health` | Hub Health | latest `hub_health_snapshots`, bottlenecks | Yes |
|
||||||
|
| `agent-proposals` | Agent Proposals | `agent_proposals`, `agent_review_records` | No |
|
||||||
|
| `api-usage` | API Usage | `api_consumers`, `api_request_log` | Yes |
|
||||||
|
| `marketplace-trending` | Marketplace Trending | `widget_patterns`, adoptions, templates | No |
|
||||||
|
| `learning-digest` | Learning Digest | `learning_insights`, `institutional_knowledge_entries` | Yes |
|
||||||
|
| `my-annotations` | My Annotations | `annotations` filtered by current user when available | No |
|
||||||
|
|
||||||
|
The implementation workplan should start with a smaller subset if needed:
|
||||||
|
`watched-hubs`, `recent-interactions`, `triage-queue`, `recent-decisions`,
|
||||||
|
`hub-health`, and `learning-digest` are enough to prove the framework.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### T01 - Current-state audit and dashboard pattern research
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0020-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "6074f195-636b-4517-b6d1-eb3c57394a82"
|
||||||
|
```
|
||||||
|
|
||||||
|
Produce a short research note that starts with the current inter-hub codebase,
|
||||||
|
then uses external dashboard systems only for secondary inspiration.
|
||||||
|
|
||||||
|
Required current-state inventory:
|
||||||
|
|
||||||
|
- Existing routes and views that behave like dashboards.
|
||||||
|
- Existing `autoRefresh` usage and query patterns that are safe to reuse.
|
||||||
|
- Existing type registry, `Widget`, `widgetEnvelope`, annotation, and event
|
||||||
|
capture constraints.
|
||||||
|
- Existing tables that can power first-slice personal panels.
|
||||||
|
- Gaps: personal dashboard persistence, panel catalogue, saved filters, layout
|
||||||
|
model, and user preference/defaulting model.
|
||||||
|
|
||||||
|
External systems may still be sampled, but the output should focus on patterns
|
||||||
|
that are practical in IHP/HSX/Tailwind:
|
||||||
|
|
||||||
|
| System | What to extract |
|
||||||
|
|---|---|
|
||||||
|
| Grafana | Panel/grid layout model, dashboard variables, bounded refresh |
|
||||||
|
| Kibana dashboards | Saved-search panels, time range filters, role visibility |
|
||||||
|
| Retool/Appsmith | Widget catalogue and data binding concepts, not their client runtime |
|
||||||
|
| Linear home view | Flat "my work" aggregation across entities |
|
||||||
|
| Notion linked databases | Saved filters/sorts as user-facing views |
|
||||||
|
| Metabase | Question-as-unit model and governed saved queries |
|
||||||
|
| Streamlit | Declarative layout vocabulary suitable for server rendering |
|
||||||
|
|
||||||
|
Questions to answer:
|
||||||
|
|
||||||
|
1. Which existing inter-hub dashboard fragments can become first-slice panel
|
||||||
|
renderers without broad refactoring?
|
||||||
|
2. Which panel configs must exist on day one: hub filter, time range, limit,
|
||||||
|
display mode, or sort order?
|
||||||
|
3. Which panels need live refresh, and which should stay static per request?
|
||||||
|
4. How should each saved panel map to a governed `Widget` row?
|
||||||
|
5. What should be explicitly deferred to avoid building a report builder?
|
||||||
|
|
||||||
|
Exit criteria: `docs/research/personal-dashboard-current-state.md` exists and
|
||||||
|
has enough evidence to drive the PRS.
|
||||||
|
|
||||||
|
Completion note (2026-06-16): added
|
||||||
|
`docs/research/personal-dashboard-current-state.md`, covering the current
|
||||||
|
dashboard inventory, AutoRefresh/query patterns, governed widget constraints,
|
||||||
|
first-slice panel candidates, external pattern extraction, and T02/T03
|
||||||
|
recommendations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T02 - Product Requirements Specification
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0020-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
depends_on: T01
|
||||||
|
state_hub_task_id: "698304bc-b91a-42e2-a617-b3ddbf749174"
|
||||||
|
```
|
||||||
|
|
||||||
|
Produce a formal PRS based on T01 and the current implementation.
|
||||||
|
|
||||||
|
Required sections:
|
||||||
|
|
||||||
|
1. Problem statement: authenticated users currently land on the Hubs list and
|
||||||
|
must manually navigate to specialized dashboards to answer daily operating
|
||||||
|
questions.
|
||||||
|
2. Personas:
|
||||||
|
- Hub operator: watches hub health, recent events, candidates, and
|
||||||
|
bottlenecks.
|
||||||
|
- Governance reviewer: triages candidates, decisions, policy coverage, and
|
||||||
|
annotations.
|
||||||
|
- AI orchestrator: watches agent proposals, review outcomes, and learning
|
||||||
|
signals.
|
||||||
|
- Platform admin: watches API usage, hub registry health, manifests, and
|
||||||
|
cross-hub propagation.
|
||||||
|
3. Core requirements using MoSCoW:
|
||||||
|
- Must: per-user saved dashboard, seeded default dashboard, panel catalogue,
|
||||||
|
server-rendered panels, persisted layout, governed panel widget identity,
|
||||||
|
post-login route design, bounded panel queries.
|
||||||
|
- Should: hub/time filters, simple edit mode, live refresh on selected
|
||||||
|
panels, keyboard-accessible forms, link-outs to existing source
|
||||||
|
dashboards.
|
||||||
|
- Could: dashboard templates, saved watched-hub sets, shared dashboards,
|
||||||
|
richer display modes.
|
||||||
|
- Won't: drag-and-drop, external datasources, client-side fetching, mobile
|
||||||
|
layout editor, complete refactor of existing dashboards.
|
||||||
|
4. Non-functional requirements:
|
||||||
|
- First paint target remains sub-second for seeded dashboards with bounded
|
||||||
|
panel queries.
|
||||||
|
- Panel queries must use limits and existing indexes or propose new indexes.
|
||||||
|
- Dashboard save/load must be simple transactional IHP controller work.
|
||||||
|
- No new JS framework.
|
||||||
|
5. Governance fit:
|
||||||
|
- Saved panel instances are governed IHF widgets or reference governed
|
||||||
|
widgets.
|
||||||
|
- Panel views use `widgetEnvelope`.
|
||||||
|
- Panel interactions emit existing event types where possible.
|
||||||
|
- Annotations attach to the panel widget identity, not to a transient DOM
|
||||||
|
block.
|
||||||
|
|
||||||
|
Exit criteria: `docs/prs/personal-dashboard-prs.md` exists and is ready for
|
||||||
|
FDD work.
|
||||||
|
|
||||||
|
Completion note (2026-06-16): added
|
||||||
|
`docs/prs/personal-dashboard-prs.md`, defining the problem statement,
|
||||||
|
personas, MoSCoW requirements, first-slice panel catalogue, governance
|
||||||
|
requirements, acceptance criteria, risks, and FDD open questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T03 - Functional Design Document
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0020-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
depends_on: T02
|
||||||
|
state_hub_task_id: "438e5771-a043-4f26-a1ce-994ed478a760"
|
||||||
|
```
|
||||||
|
|
||||||
|
Translate the PRS into a concrete FDD covering schema, controller actions,
|
||||||
|
panel renderer contract, layout, seed/default behavior, and migration strategy.
|
||||||
|
|
||||||
|
The FDD must update the old greenfield schema sketch. A likely shape is:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE personal_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(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE dashboard_panel_types (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
default_config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
default_col_span INT NOT NULL DEFAULT 4,
|
||||||
|
default_row_span INT NOT NULL DEFAULT 1,
|
||||||
|
live_update BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE dashboard_panels (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
dashboard_id UUID NOT NULL REFERENCES personal_dashboards(id) ON DELETE CASCADE,
|
||||||
|
panel_type_id UUID NOT NULL REFERENCES dashboard_panel_types(id),
|
||||||
|
widget_id UUID NOT NULL REFERENCES widgets(id),
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
col INT NOT NULL DEFAULT 0,
|
||||||
|
row INT NOT NULL DEFAULT 0,
|
||||||
|
col_span INT NOT NULL DEFAULT 4,
|
||||||
|
row_span INT NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The FDD must also resolve:
|
||||||
|
|
||||||
|
- Naming: whether tables should use `personal_dashboards` or another prefix to
|
||||||
|
avoid confusing them with existing dashboard actions.
|
||||||
|
- Panel config: JSONB storage plus explicit Haskell ADT decoding and validation.
|
||||||
|
- Governance identity: how `dashboard_panels.widget_id` is created, versioned,
|
||||||
|
and named.
|
||||||
|
- Renderer contract:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data DashboardPanelConfig
|
||||||
|
= WatchedHubsConfig WatchedHubsOptions
|
||||||
|
| RecentInteractionsConfig RecentInteractionsOptions
|
||||||
|
| TriageQueueConfig TriageQueueOptions
|
||||||
|
| RecentDecisionsConfig RecentDecisionsOptions
|
||||||
|
| HubHealthConfig HubHealthOptions
|
||||||
|
| LearningDigestConfig LearningDigestOptions
|
||||||
|
|
||||||
|
renderDashboardPanel
|
||||||
|
:: DashboardPanelType
|
||||||
|
-> DashboardPanel
|
||||||
|
-> DashboardPanelConfig
|
||||||
|
-> ModelContext
|
||||||
|
-> IO Html
|
||||||
|
```
|
||||||
|
|
||||||
|
- Layout: 12-column grid on desktop, single-column below the existing Tailwind
|
||||||
|
breakpoint, stable row/span constraints, no drag-and-drop in the first slice.
|
||||||
|
- Routes/actions:
|
||||||
|
- `PersonalDashboardAction`
|
||||||
|
- `EditPersonalDashboardAction`
|
||||||
|
- `UpdatePersonalDashboardAction`
|
||||||
|
- `AddDashboardPanelAction`
|
||||||
|
- `UpdateDashboardPanelAction`
|
||||||
|
- `RemoveDashboardPanelAction`
|
||||||
|
- Login behavior: `CreateSessionAction` should redirect to the personal
|
||||||
|
dashboard after authentication, while public root pages remain unchanged.
|
||||||
|
- Defaulting model: seed a default dashboard on first visit without requiring a
|
||||||
|
`users.role` column.
|
||||||
|
- Query safety: each panel query must be bounded, indexed, and compatible with
|
||||||
|
current PostgreSQL type decoding practices such as casting `COUNT(*)` to
|
||||||
|
integer when read as `Int`.
|
||||||
|
- Tests and smoke checks needed for the follow-on implementation workplan.
|
||||||
|
|
||||||
|
Exit criteria: `docs/fdd/personal-dashboard-fdd.md` exists, schema decisions
|
||||||
|
are concrete enough to implement, and open questions are explicitly listed.
|
||||||
|
|
||||||
|
Completion note (2026-06-16): added
|
||||||
|
`docs/fdd/personal-dashboard-fdd.md`, resolving schema names, panel config
|
||||||
|
typing, renderer/view-model shape, default seeding, governed panel widget
|
||||||
|
lifecycle, query constraints, routes, layout, tests, and handoff shape for
|
||||||
|
IHUB-WP-0021.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T04 - Implementation workplan
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0020-T04
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
depends_on: T03
|
||||||
|
state_hub_task_id: "970aa221-7e17-4500-8b37-9c98676280b1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the execution workplan for implementation as `IHUB-WP-0021`.
|
||||||
|
|
||||||
|
Expected task structure for `IHUB-WP-0021`:
|
||||||
|
|
||||||
|
| Task | Focus |
|
||||||
|
|---|---|
|
||||||
|
| T01 | Schema migration for personal dashboards, panel types, and panel instances |
|
||||||
|
| T02 | Seed dashboard panel types and any required framework `panel` widgets/type vocabulary |
|
||||||
|
| T03 | Add controller/action/route skeleton and default dashboard lookup/seed helper |
|
||||||
|
| T04 | Implement first three renderers: watched hubs, recent interactions, triage queue |
|
||||||
|
| T05 | Implement dashboard show view and responsive CSS grid |
|
||||||
|
| T06 | Implement remaining first-slice renderers: recent decisions, hub health, learning digest |
|
||||||
|
| T07 | Implement edit flow: reorder/update layout, add/remove panels, validate config |
|
||||||
|
| T08 | Add governed widget identity creation and `widgetEnvelope` wrapping for panels |
|
||||||
|
| T09 | Redirect successful login to the personal dashboard |
|
||||||
|
| T10 | Add `autoRefresh` only around selected live panels or the whole page if finer wrapping is not practical |
|
||||||
|
| T11 | Add focused tests for seeding, panel config validation, route access, and bounded queries |
|
||||||
|
| T12 | Manual smoke: login, seeded dashboard, edit layout, annotate a panel, verify source dashboards still load |
|
||||||
|
|
||||||
|
Each task must have entry criteria, exit criteria, rollback notes, and the
|
||||||
|
smallest reasonable test/smoke requirement. Keep implementation slices small
|
||||||
|
enough for Codex sessions to finish without broad refactors.
|
||||||
|
|
||||||
|
Exit criteria: `workplans/IHUB-WP-0021-personal-dashboard-implementation.md`
|
||||||
|
exists with all tasks in `todo` state and enough detail to start implementation.
|
||||||
|
|
||||||
|
Completion note (2026-06-16): added
|
||||||
|
`workplans/IHUB-WP-0021-personal-dashboard-implementation.md` with twelve
|
||||||
|
sequenced implementation tasks covering schema, seeds, controller skeleton,
|
||||||
|
panel renderers, show/edit views, governed panel widget lifecycle, login
|
||||||
|
redirect, AutoRefresh/query hardening, tests, and manual smoke.
|
||||||
|
|
||||||
|
## Exit Criteria Summary
|
||||||
|
|
||||||
|
| Task | Deliverable | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| T01 | `docs/research/personal-dashboard-current-state.md` | done |
|
||||||
|
| T02 | `docs/prs/personal-dashboard-prs.md` | done |
|
||||||
|
| T03 | `docs/fdd/personal-dashboard-fdd.md` | done |
|
||||||
|
| T04 | `workplans/IHUB-WP-0021-personal-dashboard-implementation.md` | done |
|
||||||
|
|
||||||
|
## Binding Design Principles
|
||||||
|
|
||||||
|
- Server-first: every panel renders on the server in the normal IHP request
|
||||||
|
lifecycle.
|
||||||
|
- Integration-first: reuse current dashboard query patterns before extracting
|
||||||
|
shared abstractions.
|
||||||
|
- Governed panels: saved panel instances have stable IHF widget identity and
|
||||||
|
use `widgetEnvelope`.
|
||||||
|
- Type-safe runtime config: JSONB is storage, not the unchecked runtime API.
|
||||||
|
- Bounded queries: every panel limits rows and uses existing indexes or proposes
|
||||||
|
a specific migration.
|
||||||
|
- Minimal JS: no framework and no client-side data fetch loop.
|
||||||
|
- Tailwind only: use existing view style and responsive grid conventions.
|
||||||
623
workplans/IHUB-WP-0021-personal-dashboard-implementation.md
Normal file
623
workplans/IHUB-WP-0021-personal-dashboard-implementation.md
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
---
|
||||||
|
id: IHUB-WP-0021
|
||||||
|
type: workplan
|
||||||
|
title: "Personal Dashboard Implementation"
|
||||||
|
domain: infotech
|
||||||
|
repo: inter-hub
|
||||||
|
status: ready
|
||||||
|
owner: codex
|
||||||
|
topic_slug: inter_hub
|
||||||
|
created: "2026-06-16"
|
||||||
|
updated: "2026-06-16"
|
||||||
|
phase: 13
|
||||||
|
depends_on: IHUB-WP-0020
|
||||||
|
related_docs:
|
||||||
|
- docs/research/personal-dashboard-current-state.md
|
||||||
|
- docs/prs/personal-dashboard-prs.md
|
||||||
|
- docs/fdd/personal-dashboard-fdd.md
|
||||||
|
state_hub_workstream_id: "79f72176-fb3f-4d59-9678-d42f5ff1e679"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Personal Dashboard Implementation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the personal dashboard framework designed in IHUB-WP-0020: a
|
||||||
|
server-rendered authenticated landing page with persisted per-user panels,
|
||||||
|
governed panel widget identity, default dashboard seeding, simple edit forms,
|
||||||
|
and six first-slice panel renderers.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- `docs/research/personal-dashboard-current-state.md`
|
||||||
|
- `docs/prs/personal-dashboard-prs.md`
|
||||||
|
- `docs/fdd/personal-dashboard-fdd.md`
|
||||||
|
- Existing dashboard surfaces in `Web/Controller/Hubs.hs`,
|
||||||
|
`Web/Controller/LearningDashboard.hs`, `Web/Controller/ApiDashboard.hs`,
|
||||||
|
`Web/Controller/MarketplaceDashboard.hs`, and federated governance
|
||||||
|
controllers.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep implementation additive.
|
||||||
|
- Preserve public root/static routes.
|
||||||
|
- Do not refactor all existing dashboards.
|
||||||
|
- No client-side data fetching framework.
|
||||||
|
- No drag-and-drop layout in this workplan.
|
||||||
|
- Every saved dashboard panel must have stable `Widget` identity and render
|
||||||
|
through `widgetEnvelope`.
|
||||||
|
- Bound every panel query.
|
||||||
|
- Cast aggregate `COUNT(*)` queries when decoding as `Int`, or decode as
|
||||||
|
`Int64`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### T01 - Add personal dashboard schema
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T01
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "bb7366a3-78ec-42d8-9f16-b7ed4979ec53"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the schema from the FDD:
|
||||||
|
|
||||||
|
- `personal_dashboards`
|
||||||
|
- `dashboard_panel_types`
|
||||||
|
- `dashboard_panels`
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Add an `Application/Migration/<timestamp>-personal-dashboard-framework.sql`
|
||||||
|
migration.
|
||||||
|
- Update `Application/Schema.sql` consistently with the migration.
|
||||||
|
- Use `panel_key`, not `key`, on `dashboard_panel_types`.
|
||||||
|
- Include `removed_at` on `dashboard_panels`.
|
||||||
|
- Include indexes and layout CHECK constraints from the FDD.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- IHUB-WP-0020 is done.
|
||||||
|
- FDD exists and is reviewed enough for implementation.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Schema files contain the three new tables and indexes.
|
||||||
|
- `rg "personal_dashboards|dashboard_panel_types|dashboard_panels" Application`
|
||||||
|
finds the expected migration/schema entries.
|
||||||
|
- No existing table or route behavior is changed.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Run `git diff --check`.
|
||||||
|
- If the IHP dev environment is available, run the repo compile/schema check
|
||||||
|
used by prior inter-hub workplans.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Before production data exists, rollback is removing the migration/schema
|
||||||
|
additions.
|
||||||
|
- After production data exists, rollback requires preserving linked `widgets`
|
||||||
|
and `interaction_events`; do not delete panel widgets casually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T02 - Seed panel types and framework panel vocabulary
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T02
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T01
|
||||||
|
state_hub_task_id: "d298eab2-736d-48ed-b6d4-84afa1604de9"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add idempotent seed data for:
|
||||||
|
|
||||||
|
- framework hub with slug `inter-hub` and `hub_kind = 'framework'` if absent;
|
||||||
|
- active widget type `panel` if absent;
|
||||||
|
- six first-slice `dashboard_panel_types`:
|
||||||
|
- `watched-hubs`
|
||||||
|
- `recent-interactions`
|
||||||
|
- `triage-queue`
|
||||||
|
- `recent-decisions`
|
||||||
|
- `hub-health`
|
||||||
|
- `learning-digest`
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- T01 schema exists.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Seed SQL or helper is idempotent.
|
||||||
|
- Re-running seeds does not create duplicate framework hubs, widget types, or
|
||||||
|
panel types.
|
||||||
|
- Default configs match the FDD.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Inspect seed SQL for `ON CONFLICT DO NOTHING` or equivalent idempotency.
|
||||||
|
- If DB is available, run a local seed twice and confirm row counts stay stable.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Panel type seed rows are additive. If rollback is needed before use, remove
|
||||||
|
only the new dashboard panel type rows and any framework hub created solely
|
||||||
|
for this feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T03 - Add controller skeleton, routes, and default dashboard helper
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T03
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T02
|
||||||
|
state_hub_task_id: "8a171c71-3762-46c7-88d7-10ffb87fc78a"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
- `PersonalDashboardsController` to `Web/Types.hs`;
|
||||||
|
- `Web/Controller/PersonalDashboards.hs`;
|
||||||
|
- `Web/View/PersonalDashboards/Show.hs`;
|
||||||
|
- `Web/View/PersonalDashboards/Edit.hs` placeholder or minimal views;
|
||||||
|
- route registration in `Web/Routes.hs`;
|
||||||
|
- controller import and parser registration in `Web/FrontController.hs`;
|
||||||
|
- helper module `Application/Helper/PersonalDashboard.hs`.
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- `ensureDefaultDashboard :: User -> IO PersonalDashboard`;
|
||||||
|
- dashboard lookup scoped to current user;
|
||||||
|
- idempotent default seeding with six panel rows;
|
||||||
|
- linked `Widget` creation for each seeded panel;
|
||||||
|
- initial `WidgetVersion` creation for panel widgets.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- T01/T02 complete.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Authenticated user can hit `PersonalDashboardAction`.
|
||||||
|
- First visit creates a default dashboard with six active panels.
|
||||||
|
- A second visit does not duplicate panels.
|
||||||
|
- Controller denies unauthenticated access through `ensureIsUser`.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Compile if environment is available.
|
||||||
|
- Add or run focused helper tests if existing test harness supports it.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Remove route/controller/helper additions if skeleton must be reverted.
|
||||||
|
- Keep seeded widgets/events if any user interactions already happened.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T04 - Implement first three panel view models/renderers
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T04
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T03
|
||||||
|
state_hub_task_id: "012dcd2a-d3e0-48ba-966b-f4c7afa51dad"
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement typed config decoding and view-model builders for:
|
||||||
|
|
||||||
|
- `watched-hubs`
|
||||||
|
- `recent-interactions`
|
||||||
|
- `triage-queue`
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Query from controller/helper, not from HSX views.
|
||||||
|
- Clamp configured limits.
|
||||||
|
- Apply optional hub filters.
|
||||||
|
- Render empty states.
|
||||||
|
- Include source links:
|
||||||
|
- watched hub rows link to `ShowHubAction`;
|
||||||
|
- recent interaction rows link to widget or hub context where available;
|
||||||
|
- triage rows link to `ShowRequirementCandidateAction`.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- T03 controller/helper skeleton exists.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- The three panels render in the dashboard show action.
|
||||||
|
- Invalid config falls back to defaults and records a warning in the panel view
|
||||||
|
model.
|
||||||
|
- Queries are bounded.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Compile if environment is available.
|
||||||
|
- Manual smoke with empty DB and seeded fixture data if possible.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Renderer additions are isolated to helper/view modules and can be reverted
|
||||||
|
without dropping schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T05 - Implement dashboard show view and responsive grid
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T05
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T04
|
||||||
|
state_hub_task_id: "b4c4de39-147b-45a0-954d-9bafad4aafa1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the dashboard show view:
|
||||||
|
|
||||||
|
- title and edit link;
|
||||||
|
- responsive grid;
|
||||||
|
- panel cards using row/col/span config;
|
||||||
|
- panel title fallback to panel type label;
|
||||||
|
- warning display for invalid config or unsupported panel;
|
||||||
|
- source link area;
|
||||||
|
- `widgetEnvelope` around every panel.
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Use current Tailwind and HSX conventions.
|
||||||
|
- Add a small CSS helper in `static/app.css` only if needed for responsive
|
||||||
|
collapse.
|
||||||
|
- Keep text compact and operational.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- At least three panel view models exist.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Seeded dashboard is usable as an authenticated landing surface.
|
||||||
|
- Panels do not overlap at desktop or narrow widths.
|
||||||
|
- Panel layout persists in the order defined by `row`, `col`, and `sort_order`.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Compile if environment is available.
|
||||||
|
- Browser/manual smoke if a dev server is running.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Show view changes can be reverted independently of schema/controller work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T06 - Implement remaining first-slice panel view models/renderers
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T06
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T05
|
||||||
|
state_hub_task_id: "8d0bd046-17b3-48d9-a945-8b2e9c001123"
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- `recent-decisions`
|
||||||
|
- `hub-health`
|
||||||
|
- `learning-digest`
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Recent decisions: bounded by time range and limit; link to
|
||||||
|
`ShowDecisionRecordAction`.
|
||||||
|
- Hub health: latest snapshot per hub plus active bottleneck count; use
|
||||||
|
aggregate count casting or `Int64`.
|
||||||
|
- Learning digest: recent `learning_insights` and
|
||||||
|
`institutional_knowledge_entries`; link to knowledge entries where possible.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- T05 show view can render panel view models.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- All six first-slice panels render.
|
||||||
|
- All panel queries are bounded.
|
||||||
|
- Empty states are sane for all six panels.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Compile if environment is available.
|
||||||
|
- `git diff --check`.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Each renderer should be separable so a single broken panel can be reverted
|
||||||
|
without removing the framework.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T07 - Implement edit flow
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T07
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T06
|
||||||
|
state_hub_task_id: "51a72b56-5c23-4baa-892f-4ab89fd8495c"
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- `EditPersonalDashboardAction`;
|
||||||
|
- `UpdatePersonalDashboardAction`;
|
||||||
|
- `AddDashboardPanelAction`;
|
||||||
|
- `UpdateDashboardPanelAction`;
|
||||||
|
- `RemoveDashboardPanelAction`.
|
||||||
|
|
||||||
|
Edit view capabilities:
|
||||||
|
|
||||||
|
- show existing panels in layout order;
|
||||||
|
- edit row, col, col span, row span, title, and sort order;
|
||||||
|
- edit supported config fields such as limit, time range, display mode, and
|
||||||
|
hub filter;
|
||||||
|
- add active panel type;
|
||||||
|
- remove panel.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- All six panels render on show view.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- User can modify layout and config through server-rendered forms.
|
||||||
|
- User can add a panel and remove a panel.
|
||||||
|
- Invalid layout/config re-renders edit view with an error.
|
||||||
|
- A user cannot edit another user's dashboard/panel.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Manual edit smoke.
|
||||||
|
- Focused authorization/config tests if available.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- If edit flow is unstable, keep show-only dashboard and disable edit links
|
||||||
|
until fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T08 - Complete governed panel widget lifecycle
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T08
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T07
|
||||||
|
state_hub_task_id: "ca70b76d-766f-4e6e-84f1-19943c0a347c"
|
||||||
|
```
|
||||||
|
|
||||||
|
Harden widget lifecycle behavior:
|
||||||
|
|
||||||
|
- create panel widget on panel add/seed;
|
||||||
|
- create initial `WidgetVersion` snapshot;
|
||||||
|
- create a new `WidgetVersion` snapshot when material panel config changes;
|
||||||
|
- render every panel through `widgetEnvelope`;
|
||||||
|
- preserve annotations/events when panels are removed;
|
||||||
|
- archive/deprecate linked widget on panel removal.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- Edit flow can add/remove/update panels.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Every active dashboard panel has a linked active `Widget`.
|
||||||
|
- Every linked widget has non-empty `view_context`.
|
||||||
|
- Annotate link opens the existing widget annotation flow.
|
||||||
|
- Removing a panel does not delete widget history.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Manual smoke: add panel, annotate panel, remove panel, confirm widget/event
|
||||||
|
history is not deleted.
|
||||||
|
- Inspect generated HTML for expected `data-widget-*` attributes.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Do not delete existing `widgets`, `annotations`, or `interaction_events`.
|
||||||
|
Disable dashboard rendering if needed while preserving history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T09 - Redirect login and add navigation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T09
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
depends_on: T08
|
||||||
|
state_hub_task_id: "2fd1041b-9135-49e4-a9d1-e5d0b67d8fd7"
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- `Web/Controller/Sessions.hs` to redirect successful login to
|
||||||
|
`PersonalDashboardAction`;
|
||||||
|
- `Web/FrontController.hs` sidebar to include `Dashboard`;
|
||||||
|
- any relevant public page management links only if they should point to the
|
||||||
|
dashboard rather than Hubs.
|
||||||
|
|
||||||
|
Do not change:
|
||||||
|
|
||||||
|
- public root route;
|
||||||
|
- `LandingAction`;
|
||||||
|
- capabilities/tutorial/extension guide pages;
|
||||||
|
- `HubsAction` availability.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- Dashboard show route is stable and governed panel lifecycle is complete.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Successful login lands on personal dashboard.
|
||||||
|
- Hubs remain reachable from sidebar.
|
||||||
|
- Public pages still render without login.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Manual login smoke.
|
||||||
|
- Route smoke for `/`, Hubs, dashboard, Learning, API Dashboard, Marketplace.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- If dashboard redirect fails, revert only the login redirect and keep
|
||||||
|
dashboard accessible from sidebar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10 - Add AutoRefresh and query hardening pass
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T10
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
depends_on: T09
|
||||||
|
state_hub_task_id: "e19c06e7-ec95-40ca-8087-df669b575f86"
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap `PersonalDashboardAction` in `autoRefresh do` and audit all six panel
|
||||||
|
queries:
|
||||||
|
|
||||||
|
- every query is bounded;
|
||||||
|
- optional hub filter is applied before broad fetches where practical;
|
||||||
|
- aggregate counts decode safely;
|
||||||
|
- no secrets are selected or displayed;
|
||||||
|
- dashboard refresh remains acceptable with default seed data.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- Dashboard route, renderers, edit flow, and login redirect exist.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Dashboard updates using existing IHP AutoRefresh behavior.
|
||||||
|
- Query review notes are either captured in code comments or tests where useful.
|
||||||
|
- No known `COUNT(*)` as `Int` decode hazard remains in dashboard code.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Compile if environment is available.
|
||||||
|
- Manual refresh smoke by adding an interaction/candidate and observing the
|
||||||
|
dashboard update, when a dev DB is available.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Remove `autoRefresh` wrapper if it causes unacceptable behavior; keep static
|
||||||
|
dashboard route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11 - Add focused tests
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T11
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
depends_on: T10
|
||||||
|
state_hub_task_id: "32b4f55e-ede6-4830-a171-b0785afe88e1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add focused tests where the current harness supports them:
|
||||||
|
|
||||||
|
- default dashboard seeding is idempotent;
|
||||||
|
- seeded dashboard has six active panels;
|
||||||
|
- each active panel has linked widget identity;
|
||||||
|
- config decoder clamps limits and rejects unknown values safely;
|
||||||
|
- remove action soft-removes panel and archives widget;
|
||||||
|
- users cannot edit another user's dashboard;
|
||||||
|
- aggregate counts in dashboard helpers decode safely.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- T10 implementation is stable enough to test.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Relevant test files exist or a documented reason explains why the current
|
||||||
|
harness cannot cover a case.
|
||||||
|
- Tests pass where runnable in the local environment.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Run available test command.
|
||||||
|
- If unavailable, record exact blocker in this workplan before closing T11.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- Do not weaken production behavior to satisfy a brittle test; adjust the test
|
||||||
|
to match the intended FDD contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12 - Manual smoke and closeout
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0021-T12
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
depends_on: T11
|
||||||
|
state_hub_task_id: "8c6648ae-d33e-48f6-9d56-ee557f367d80"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a manual smoke pass:
|
||||||
|
|
||||||
|
1. Log in as an existing admin user.
|
||||||
|
2. Confirm redirect lands on personal dashboard.
|
||||||
|
3. Confirm all six seeded panels render.
|
||||||
|
4. Click source links from watched hubs, triage queue, and learning digest.
|
||||||
|
5. Open Annotate for one panel.
|
||||||
|
6. Edit layout and save.
|
||||||
|
7. Sign out/in and confirm layout persists.
|
||||||
|
8. Add and remove a panel.
|
||||||
|
9. Confirm Hubs, Hub show, Ops Review, Federation, Learning, API Dashboard,
|
||||||
|
Hub Registry, and Marketplace still load.
|
||||||
|
10. Run `git diff --check`.
|
||||||
|
|
||||||
|
Entry criteria:
|
||||||
|
|
||||||
|
- T01 through T11 complete or have explicit accepted caveats.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Smoke evidence is recorded in this workplan or a short docs/evidence note.
|
||||||
|
- WP-0021 tasks reflect final status.
|
||||||
|
- State Hub progress note is logged.
|
||||||
|
- Operator is reminded to run `make fix-consistency REPO=inter-hub` from
|
||||||
|
`~/state-hub` after workplan/status changes.
|
||||||
|
|
||||||
|
Rollback notes:
|
||||||
|
|
||||||
|
- If smoke fails after login redirect, first rollback is reverting the login
|
||||||
|
redirect while keeping dashboard route available for debugging.
|
||||||
|
|
||||||
|
## Workplan Exit Criteria
|
||||||
|
|
||||||
|
- Personal dashboard schema, seeds, controller, views, helper, and route are
|
||||||
|
implemented.
|
||||||
|
- Successful login reaches the personal dashboard.
|
||||||
|
- Default dashboard seeding is idempotent.
|
||||||
|
- Six first-slice panels render with bounded queries.
|
||||||
|
- Panel edit flow works.
|
||||||
|
- Every panel has governed widget identity.
|
||||||
|
- Existing source dashboards remain functional.
|
||||||
|
- Checks/smoke evidence is recorded.
|
||||||
440
workplans/IHUB-WP-0022-ops-hub-evidence-intake.md
Normal file
440
workplans/IHUB-WP-0022-ops-hub-evidence-intake.md
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
---
|
||||||
|
id: IHUB-WP-0022
|
||||||
|
type: workplan
|
||||||
|
title: "Ops Hub Evidence Intake for Activity Core"
|
||||||
|
domain: infotech
|
||||||
|
repo: inter-hub
|
||||||
|
status: active
|
||||||
|
owner: codex
|
||||||
|
topic_slug: inter_hub
|
||||||
|
created: "2026-06-15"
|
||||||
|
updated: "2026-06-16"
|
||||||
|
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: done
|
||||||
|
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.
|
||||||
|
|
||||||
|
Implementation note (2026-06-16): completed fallback-first validation using
|
||||||
|
Railiance cluster-owned verifier evidence. State Hub progress
|
||||||
|
`db408146-0310-4ac3-ac77-f73c5a41e070` records a live
|
||||||
|
`ops_inventory_probe` summary from activity-core:
|
||||||
|
`0 ok, 4 degraded, 0 down, 5 skipped`. Railiance evidence note
|
||||||
|
`60256e9a-9d1b-44db-8999-738cf03bca2e` proves the progress event matched the
|
||||||
|
manual trigger run id `90e3b112-d1e3-51af-8fb2-cb61f26add17` and includes the
|
||||||
|
live `actcore-api` image digest. Updated the validation document with the
|
||||||
|
evidence, gaps, and closure recommendation. Inter-Hub per-entity submission
|
||||||
|
remains deferred to T03/T04/T07.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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: done
|
||||||
|
priority: medium
|
||||||
|
depends_on: T06
|
||||||
|
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.
|
||||||
|
|
||||||
|
Implementation note (2026-06-16): completed the activity-core closure handoff
|
||||||
|
on the fallback-deferred path. `ACTIVITY-WP-0007/T06` is already closed in
|
||||||
|
activity-core and State Hub. Inter-Hub accepts that closure on live State Hub
|
||||||
|
fallback evidence (`ops_inventory_probe`
|
||||||
|
`db408146-0310-4ac3-ac77-f73c5a41e070`) with explicit deferral of governed
|
||||||
|
Inter-Hub submissions until the ops-hub manifest/widget path, runtime key, and
|
||||||
|
end-to-end smoke are complete under T03, T04, and T07. No secret values or
|
||||||
|
runtime key material are required for this handoff.
|
||||||
|
|
||||||
|
## 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` | done |
|
||||||
|
| T07 | End-to-end Inter-Hub submission smoke evidence | wait |
|
||||||
|
| T08 | activity-core closure handoff | done |
|
||||||
|
|
||||||
|
## 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