Compare commits

334 Commits

Author SHA1 Message Date
b90320e4e7 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-23:
  - update .custodian-brief.md for inter-hub
2026-06-23 21:49:12 +02:00
9582bf5277 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-23:
  - update .custodian-brief.md for inter-hub
2026-06-23 21:34:37 +02:00
f27e1551c9 Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
All checks were successful
Build and Deploy / build-push-deploy (push) Successful in 3m45s
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:25 +02:00
e93145966f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for inter-hub
2026-06-22 12:39:32 +02:00
e558d3deba Mark .repo-classification.yaml human-reviewed (CUST-WP-0050 T02)
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:40:43 +02:00
dbab7ac1d7 Add repo classification (CUST-WP-0050 T02)
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
First-pass agent classification per the Repo Classification Standard v1.0
(canon-repo-classification); pending human review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:44:46 +02:00
be419ddb80 chore: add credential routing guidance to agent instructions
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Inline ops-warden credential routing canon into AGENTS.md and CLAUDE.md
so agents route secret requests through warden route instead of messaging
ops-warden on State Hub.
2026-06-21 16:11:49 +02:00
ee7948ba5f chore: mark ADHOC-2026-06-15 blocked pending production deploy evidence
Source-side COUNT decode fixes are complete locally, but production closure
still requires operator key handoff or deploy/smoke evidence. Add 2026-06-16
recheck notes and set workstream status to blocked.
2026-06-21 16:11:42 +02:00
e8e8187946 docs: close ops-hub evidence intake T06 and T08 with live validation
Record live ops_inventory_probe fallback evidence from activity-core and
mark IHUB-WP-0022 T06/T08 done with the activity-core closure handoff on
the fallback-deferred path.
2026-06-21 16:11:42 +02:00
d6b655a5cf docs: complete personal dashboard framework and implementation plan
Finish IHUB-WP-0020 design work (status finished, all design tasks done)
and add IHUB-WP-0021 with the 12-task implementation workplan plus
research, PRs, and FDD deliverables produced during the 2026-06-16 review.
2026-06-21 16:11:37 +02:00
a8292b639b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for inter-hub
2026-06-21 16:07:55 +02:00
3d19ba6929 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-16:
  - update .custodian-brief.md for inter-hub
2026-06-16 23:09:51 +02:00
bf04534395 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-16:
  - update .custodian-brief.md for inter-hub
2026-06-16 12:42:54 +02:00
eed4322055 Add capability registry scaffold (REUSE-WP-0014-T05 B03)
All checks were successful
Build and Deploy / build-push-deploy (push) Successful in 3m13s
2026-06-16 01:53:51 +02:00
74b2bf1ad1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - update .custodian-brief.md for inter-hub
2026-06-15 23:37:06 +02:00
a280fb12cd chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - ADHOC-2026-06-15-T01: progress → wait
2026-06-15 23:36:58 +02:00
752c87986c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - ADHOC-2026-06-15-T01: wait → progress
2026-06-15 23:34:21 +02:00
68c66b9cd9 chore: trigger inter-hub deploy 2026-06-15 23:25:32 +02:00
f8fde35e3e Implement ops-hub evidence intake contracts 2026-06-15 22:48:31 +02:00
5e7b2cd11a chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - update .custodian-brief.md for inter-hub
2026-06-15 22:45:19 +02:00
210cd3fe61 Add ops-hub evidence intake workplan 2026-06-15 22:35:19 +02:00
3511a0c1f4 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - update .custodian-brief.md for inter-hub
2026-06-15 22:34:18 +02:00
b77e1bec14 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - update .custodian-brief.md for inter-hub
2026-06-15 17:18:53 +02:00
24a8f34754 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - ADHOC-2026-06-15-T01: wait → progress
2026-06-15 16:36:39 +02:00
0833822e64 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-15:
  - update .custodian-brief.md for inter-hub
2026-06-15 16:30:23 +02:00
5101eb5c73 Fix API count decoding
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-06-15 15:49:03 +02:00
5c13de1b8f Make hub discovery public
All checks were successful
Build and Deploy / build-push-deploy (push) Successful in 3m6s
2026-06-14 22:48:53 +02:00
2e450e3a2d Record ops-hub bootstrap gate verification 2026-06-14 21:57:45 +02:00
1ba64dd00f docs(deploy): record production gate recovery 2026-06-14 21:47:03 +02:00
e4e13ff1fd docs(deploy): record inter-hub DNS gate finding 2026-06-14 20:42:12 +02:00
645590268e ci: harden inter-hub production smoke gate
Some checks failed
Build and Deploy / build-push-deploy (push) Failing after 4m4s
2026-06-14 19:59:00 +02:00
e9a9eaa607 chore(deploy): add custody recovery drill target [skip ci] 2026-06-14 18:33:50 +02:00
1a7e6afabf chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-14:
  - update .custodian-brief.md for inter-hub
2026-06-14 17:59:35 +02:00
d93185269b chore(deploy): add encrypted runtime secret source [skip ci] 2026-06-14 17:58:11 +02:00
c2009b300e chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-14:
  - update .custodian-brief.md for inter-hub
2026-06-14 16:49:40 +02:00
333fbcc237 chore(deploy): add railiance handoff guardrails [skip ci] 2026-06-14 16:47:24 +02:00
fde5525170 chore(consistency): sync task status from DB [auto]
All checks were successful
Build and Deploy / build-push-deploy (push) Successful in 1m42s
Updated by fix-consistency on 2026-06-14:
  - update .custodian-brief.md for inter-hub
2026-06-14 15:52:11 +02:00
c685848af5 docs(workplan): record inter-hub deployment recovery [skip ci] 2026-06-14 15:49:30 +02:00
5663fab495 fix(ci): allow expected unauthorized smoke response
All checks were successful
Build and Deploy / build-push-deploy (push) Successful in 2m23s
2026-06-14 15:38:26 +02:00
6f9e261eb1 fix(ci): align smoke test with current API routes
Some checks failed
Build and Deploy / build-push-deploy (push) Failing after 1m37s
2026-06-14 15:35:20 +02:00
5a686f4630 fix(ci): use registry token secret for image publish
Some checks failed
Build and Deploy / build-push-deploy (push) Failing after 38s
2026-06-14 15:21:51 +02:00
9020670bb3 fix(ci): publish images with registry bearer token
Some checks failed
Build and Deploy / build-push-deploy (push) Failing after 38s
2026-06-14 15:16:52 +02:00
5ac4c453b8 fix(deploy): use reachable gitea registry host
Some checks failed
Build and Deploy / build-push-deploy (push) Failing after 5m54s
2026-06-14 15:00:59 +02:00
a2d0dddddd fix(api): unblock production build
Some checks failed
Build and Deploy / build-push-deploy (push) Failing after 8m21s
2026-06-14 14:42:11 +02:00
84ee797e4f chore: mark inter-hub deploy blocked on runner substrate
Some checks failed
Build and Deploy / build-push-deploy (push) Failing after 7m7s
2026-06-07 19:41:25 +02:00
7cc3173f59 chore: sync IHUB-WP-0020 state hub ids
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-06-07 19:15:38 +02:00
fa96fb859a chore: improve local setup and renumber dashboard workplan
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-06-07 19:03:43 +02:00
26708ba799 chore: close IHUB-WP-0010 status
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-06-07 17:42:26 +02:00
a2c3a69b6e chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-06-07:
  - update .custodian-brief.md for inter-hub
2026-06-07 17:40:39 +02:00
91037a4757 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-06-05:
  - update .custodian-brief.md for inter-hub
2026-06-05 22:52:56 +02:00
ae9e4971d9 chore: record railiance deployment review
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-06-05 22:36:36 +02:00
a3d980c8c6 chore: sync railiance deployment workplan tasks
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Adds explicit task blocks and State Hub task IDs for IHUB-WP-0018 so WSJF triage no longer treats the active deployment workplan as empty or close-out-ready.
2026-06-04 08:26:19 +02:00
4381768045 test: add ops hub bootstrap smoke script
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-19 02:49:14 +02:00
45dbe81d57 docs: align v2 bootstrap api contract 2026-05-19 02:40:21 +02:00
5d5e810886 feat: add vsm hub metadata
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-19 02:16:39 +02:00
75ad691dd6 feat: add v2 api consumer bootstrap endpoints 2026-05-19 01:56:48 +02:00
e1c0f46a67 Refresh agent instruction files
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-18 16:55:43 +02:00
50735bb7cf feat: add v2 manifest bootstrap endpoints
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-16 09:06:15 +02:00
4ebc04e1f4 feat: add v2 hub and widget create endpoints
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-16 08:34:20 +02:00
0a4646bf44 fix: honor v2 interaction event contract 2026-05-16 04:32:58 +02:00
301a7b96d0 docs: add vsm hub bootstrap hardening plan
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-16 04:06:39 +02:00
790b5e5005 fix(ui): increase spacing between About/Tutorial/Extend links
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:34:55 +02:00
00df328214 docs(wp): IHUB-WP-0018 personal dashboard framework
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Four-task research-first workplan: T01 surveys Grafana/Kibana/Retool/
Linear/Notion/Observable/Metabase/Streamlit for transplantable patterns;
T02 produces a PRS (4 personas, MoSCoW, perf NFRs, IHF governance fit);
T03 produces an FDD (schema, 8-panel catalogue, 12-col grid layout,
server-rendered pipeline, edit flow, default seeding); T04 breaks the
FDD into IHUB-WP-0019 implementation tasks.

Design constraints: server-first, type-safe PanelConfig ADT, IHF widget
envelope on every panel, Tailwind + CSS Grid, minimal JS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:22:18 +02:00
61dfe126e8 feat(ui): nav spacing + conditional sign-in/sign-out
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Split public links (About, Tutorial, Extend) and auth into two groups
with 2rem gap between them and the separator. Uses inline style gap
to avoid Tailwind CSS bundle gaps.

Auth link is now session-aware: shows "Sign out" (POST form with
DELETE override) when logged in, "Sign in" (href to NewSessionAction)
when not. Implemented via currentUserOrNothing @User in the layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:20:45 +02:00
c74fb8fddd docs(adhoc): update layout-fix build time (10 min via .hi cache)
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-03 01:07:02 +02:00
f5eac4a4f2 docs(adhoc): sidebar nav session — profiling + improvement suggestions
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Documents the 2026-05-03 session covering registry page crashes,
logout 405, wrong password hash format, and the left sidebar
navigation refactor. Includes wall-clock profiling per build and
five improvement suggestions (GHCi loop, Nix cache, package split,
Tailwind safelist, IHP NameSupport rename strategy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:06:48 +02:00
e8b0c7c554 fix(ui): sidebar layout — inline styles for flex-col/flex-1
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
flex-col and flex-1 were absent from the compiled prod.css (Tailwind only
bundles classes that appeared in templates at build time; these were new).
The body ended up as flex-row, placing the top nav beside the sidebar
instead of above it.

Replace Tailwind layout-structural classes with inline styles on body and
the sidebar wrapper so the column layout is independent of the CSS bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:01:35 +02:00
08d662daca feat(ui): left sidebar navigation
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Move all operational links out of the top nav and into a grouped left
sidebar (192px). Top nav retains only the inter-hub logo (left) and
About / Tutorial / Extend / Sign out (right). Sidebar groups:
Core, Governance, Intelligence, Platform, Registry, API & Market.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 00:51:19 +02:00
6078c48289 fix: registry list crash and logout 405
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
IHP NameSupport cannot parse trailing-underscore field names at runtime.
orderByAsc #label_ in all four registry list actions (and the API V2
equivalents) crashed the page with ParseErrorBundle. Changed to orderByAsc
#name which avoids the NameSupport conversion path entirely.

textField #label_ in the four registry form views has the same issue.
Replaced with a plain <input> element that reads entry.label_ directly.

Logout <a href={DeleteSessionAction}> sent GET but IHP requires DELETE.
IHP includes methodOverridePost middleware, so a POST form with
_method=DELETE handles this correctly.

Also corrected the seed admin-user migration hash from bcrypt to the
pwstore-fast format (sha256|17|...) that IHP actually uses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 00:05:02 +02:00
29f7895ce8 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 23:53:11 +02:00
69b10469ab chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 23:35:15 +02:00
bbb0be4f5f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 23:18:57 +02:00
7194cfc880 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 23:02:17 +02:00
c28762ce92 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 22:46:15 +02:00
bee65a1bf6 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 22:29:48 +02:00
e7f84c5a2c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 22:13:53 +02:00
ca5ced7ac1 This seems to be our first runnable version on railiance01
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-02 22:08:38 +02:00
36c2b3874c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 21:57:12 +02:00
a8545c1fe6 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 21:40:46 +02:00
515835ab05 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 19:40:09 +02:00
9e579839af chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 19:24:09 +02:00
10c1317cf3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 19:08:13 +02:00
3e483e4785 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 18:52:12 +02:00
a8ecce80e9 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 18:36:12 +02:00
e0b0841f72 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 18:20:12 +02:00
0dd8f5f9a9 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 18:04:05 +02:00
0c9dd3dd65 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 17:48:42 +02:00
a60cc24914 fix(deploy): remove broken init container, document registry push workaround
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
The Helm init container used /bin/RunProdServer which doesn't exist in IHP's
Nix Docker image (binary is at a Nix store path). Additionally, IHP v1.5's
RunProdServer starts the server after migrating — it never exits — so init
containers are the wrong pattern. IHP applies schema changes on startup.

Changes:
- Remove initContainers block from deployment.yaml entirely
- Set runMigrations: false as default in values.yaml
- Update RUNBOOK.md with correct skopeo push procedure (pre-fetch bearer token
  to work around Gitea's misconfigured token realm URL: port 80 vs actual 32166)
- Add note that the Nix image has no /bin/sh or /bin/RunProdServer wrapper

k3s registry auth: credentials added to /etc/rancher/k3s/registries.yaml and
iptables DNAT rule added on Railiance01 (92.205.130.254:80 → 32166) so the
ACME token realm redirect works. hub.coulomb.social DNS A record still needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:45:59 +02:00
8780f6ad86 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 17:31:50 +02:00
ea88176785 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 17:15:16 +02:00
8aee7825c7 fix(build): simplify GHC 9.10.3 overlay — drop Generated.Types stub
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
The inter-hub-lib postUnpack that expanded `import Generated.Types` to
119 individual imports was incorrect: it deleted `module Generated.Types`
from Prelude export lists without replacing it, so consumers of the
Prelude lost all entity types (Build 32: GHC-76037 not-in-scope errors).

Fix: keep Generated.Types as a real module in inter-hub-models (remove the
empty stub). With the ActualTypes.hi fix already in place (explicit T(..)
exports), the cascade is shallow: each entity .hi is compact, so
Generated.Types.hi stays well under GHC's 274 MB limit. This makes
`import Generated.Types` work normally throughout inter-hub-lib without
any source patching.

The entire inter-hub-lib overrideAttrs block is removed; the
inter-hub-models overlay now only rewrites ActualTypes.hs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:09:00 +02:00
80512727cb chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 16:59:02 +02:00
f39ec84b29 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 16:43:18 +02:00
b03781360b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 16:26:34 +02:00
f800d760c8 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 15:45:46 +02:00
a35009d509 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 15:29:46 +02:00
2e6932f787 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 15:13:46 +02:00
b4415659d4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 14:57:46 +02:00
7866789303 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 14:41:46 +02:00
568970a79f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 14:25:46 +02:00
0d0c1564b4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 14:09:46 +02:00
b2b070e7c2 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 13:54:29 +02:00
591462105e chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 13:37:56 +02:00
3934481cfe chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 13:21:59 +02:00
89b9967d51 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 13:05:56 +02:00
e358382ec4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 12:49:59 +02:00
ae33a711ed chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 12:34:05 +02:00
3f995e2e4b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 12:17:56 +02:00
4c13dac5d0 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 12:02:03 +02:00
4b94b6a8f9 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 11:48:33 +02:00
c1959b0b17 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 11:28:37 +02:00
11ff61c1ba fix(build): route TH to ghc-iserv-dyn to bypass truncated libHSghc.a
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Root cause: libHSghc-9.10.3-5702.a (287,768,576 bytes) has its last AR
entry (Expr.o) claiming 517,544 bytes but only 82,258 bytes remain —
the archive is truncated. GHC's internal readAr (Data.Binary.Get) panics
at position 287,686,318 when it tries to read the full claimed size.

The truncated .a is read lazily: IHP's TH splices queue a dependency on
the ghc package, which flushes to readAr after all 477 modules compile.
This explains the invariant crash at [477 of 477] WidgetVersionInclude.

ghc-iserv-dyn is not exposed in ghc-with-packages/bin/ (why
-fexternal-interpreter alone silently fell back to the internal linker).
Use -pgmi with the absolute path in the unwrapped GHC store to force
iserv-dyn, which uses dlopen on libHSghc.so (intact, 110 MB) instead
of readAr on the truncated .a. No crash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 11:11:07 +02:00
accfec84ec chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 11:09:52 +02:00
d6cf995f05 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 10:53:24 +02:00
1ecf63e855 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 10:37:18 +02:00
6e210561b2 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 10:20:52 +02:00
15f6ef81c0 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 10:04:39 +02:00
3534952f4a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 09:48:40 +02:00
7d0c13319b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 02:47:39 +02:00
5494e2b98b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 02:32:38 +02:00
4a0d95ace9 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 02:17:29 +02:00
968ba3d282 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 02:02:25 +02:00
e6705afa5b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 01:47:19 +02:00
d1cdf9a022 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 01:32:39 +02:00
f9c0bacc1c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 01:17:57 +02:00
eb996cb092 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 01:03:24 +02:00
ec8aa611b8 chore(build): swap -fexternal-interpreter for --disable-shared on models
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
-fexternal-interpreter had no effect: crash invariant at 287,686,318.
System ar reads libHSghc-9.10.3-5702.a (287,768,576 bytes) fine, but GHC's
internal readAr fails on the last entry — a bug in GHC's AR parser when
reading content near end-of-file. The call site is mergeObjectFiles during
.so creation, not TH evaluation.

--disable-shared skips the shared library build for inter-hub-models,
preventing GHC from ever calling readAr on libHSghc.a.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:49:14 +02:00
a3d9a1effc chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 00:48:42 +02:00
c078ec441b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 00:34:34 +02:00
1050af9533 chore(build): try -fexternal-interpreter to bypass internal static linker
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
GHC crashes at byte 287,686,318 reading libHSghc-9.10.3.a (~274 MB) via
its internal static linker during TH evaluation of WidgetVersionInclude.
-fexternal-interpreter delegates TH to a separate iserv process using the
dynamic linker, bypassing readAr and the 274 MB archive entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:26:27 +02:00
059637ca5b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 00:19:48 +02:00
e117f78ef3 chore(debug): force -j1 to serialize GHC parallel code generation
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
With -j8, GHC generates code for 8 modules in parallel. A parallel merging
step might read combined objects via Data.Binary.Get hitting 287 MB. Forcing
-j1 serializes codegen to test if parallel merging is the crash cause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:06:31 +02:00
da556aa824 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-02:
  - update .custodian-brief.md for inter-hub
2026-05-02 00:05:53 +02:00
4c0d966d38 chore(debug): add -fno-dynamic-too to skip combined static/dynamic pass
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
DynamicToo state: DT_Dyn appears right before the crash. Force separate
static and dynamic GHC compilation passes to change the code path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:02:10 +02:00
01902040da chore(debug): try --disable-split-sections to avoid GHC crash
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Crash invariantly at position 287,686,318 bytes happens after all 477 modules
compile. Hypothesis: split-sections expands ELF section count, triggering
GHC's internal ELF merger/linker to fail when reading the combined object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:54:02 +02:00
f036df4f2c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 23:50:03 +02:00
91204731ae chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 23:34:56 +02:00
3664af59f2 chore(debug): add -ddump-if-trace to inter-hub-models to diagnose GHC crash
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
All 477 modules compile successfully but GHC panics at position 287,686,318
during finalization. Trace will show which .hi file is being read at crash time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:27:21 +02:00
5fe1f7bfac chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 23:20:02 +02:00
5382a7672a fix(build): capture type aliases in ActualTypes hub export list
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
IHP entity pattern: data Foo' params = Foo {...} (primed type, unprimed ctor)
                   type Foo = Foo' arg1 arg2    (concrete alias, kind *)

Include type instances use [Foo] — needs the concrete type alias (kind *),
not the primed data type. Previous awk only matched data/newtype, missing
the type alias. Add /^type [A-Z]/ match (no (..) suffix — type aliases are
not ADTs). type instance lines start with lowercase 'i' and don't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:13:26 +02:00
e19d7deef4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 23:05:22 +02:00
1bdbce96e6 fix(build): rewrite ActualTypes hub with explicit T(..) re-exports
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Previous attempt (stubs + direct imports) broke qualified constructor
references like Generated.ActualTypes.WidgetVersion in Include files —
removing the hub from scope invalidated all qualified names through it.

New approach: rewrite Generated.ActualTypes.hs in postUnpack to replace
the `module M` export list with explicit T(..) re-exports. Explicit
re-exports store only name references in the .hi file (compact), while
`module M` embeds the full sub-interface (~287 MB for 61 modules). Hub
stays functional — consumers still qualify via Generated.ActualTypes.

Also deduplicate with sort -u in case PrimaryKeys and entity files both
declare the same ID type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:04:57 +02:00
fba86d845f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 22:50:19 +02:00
98a6d3bde4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 22:35:38 +02:00
881fef28cc fix(build): stub ActualTypes hub + patch importers to avoid 274 MB .hi crash
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Generated.ActualTypes uses `module M` re-export syntax for 61 sub-modules;
GHC 9.10.3 embeds all 61 full sub-interfaces into ActualTypes.hi (~287 MB),
hitting the binary-deserialization limit at position 287686318.

Revert Cabal sub-library split (did not help — models-inner also crashed
with only 61 modules at the same invariant position). Apply the same fix
already working for Generated.Types in inter-hub-lib:

- inter-hub-models postUnpack: stub Generated.ActualTypes.hs + Generated.Types.hs
  to empty modules; patch every importer with direct sub-module imports (reads
  original ActualTypes.hs before stubbing to build the replacement import list)
- inter-hub-lib postUnpack: same for both hubs (each package has its own
  sourceRoot with originals intact)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:34:09 +02:00
9a7e6ad9f8 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 22:20:53 +02:00
ce18636038 fix(nix): cabal-version 3.0 for sublibrary dependency syntax
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
cabal-version: 2.2 does not support the pkg:sublibrary reference
syntax. Bump to 3.0 which explicitly supports it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:18:03 +02:00
a9648f302f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 22:06:00 +02:00
5378eb881e fix(nix): split inter-hub-models into two Cabal library components
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
GHC 9.10.3 crashes with Data.Binary.Get.runGet at position 287686318
invariantly when compiling all 476 inter-hub-models modules in a single
--make invocation. Split into two library components to force two
separate GHC compilations:

  models-inner (~63 modules): Generated.ActualTypes.* + Generated.Enums
    Pure type definitions; zero inter-hub-models dependencies.
  main library (~413 modules): entity ops + Include instances
    Depends on models-inner.

Longer-term this is the right architecture: explicit boundaries reduce
build cost, isolate changes, and make diagnostics cheaper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:51:47 +02:00
76347ae1b5 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 21:51:38 +02:00
6526aa66c3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 21:36:23 +02:00
a1dda36e50 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 21:21:26 +02:00
2adade749e chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 21:06:18 +02:00
827c78287f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 20:51:28 +02:00
563e19089a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 20:36:30 +02:00
f75d4196c3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 20:22:49 +02:00
5ba771c95c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 20:06:37 +02:00
92a362d91a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 19:51:40 +02:00
e9f8e168ed chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 19:36:42 +02:00
007d1a2658 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 19:21:42 +02:00
e7a9a92b0f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 19:06:49 +02:00
b45a3020ea chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 18:51:48 +02:00
7386d3b357 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 18:36:46 +02:00
b6afbbfdf3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 18:21:45 +02:00
adf3a3c5ab chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 18:06:43 +02:00
650abda494 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 17:51:40 +02:00
00edb83101 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 17:36:39 +02:00
7ba33f0ea4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 17:21:34 +02:00
1a9ac7f974 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 17:06:34 +02:00
d42352f46c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 16:51:33 +02:00
dfd8582095 fix(nix): strip module-M re-export syntax from Generated.ActualTypes
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Generated.ActualTypes uses "module M" for 61 sub-modules, causing GHC
to embed each sub-interface verbatim into ActualTypes.hi. That file hits
the GHC 9.10.3 Data.Binary.Get 274 MB limit (position 287686318) when
WidgetVersionInclude reads it during inter-hub-models compilation.

Removing the explicit (module M, ...) export list keeps the same
re-export semantics (no explicit list = export all imports) but forces
GHC to store compact name-reference entries instead of embedded copies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:48:56 +02:00
5b8f0b9175 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 16:36:21 +02:00
e017169390 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 16:21:13 +02:00
168a7ba763 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 16:06:06 +02:00
c3fd74fe03 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 15:50:59 +02:00
922998ddd5 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 15:35:56 +02:00
13b57c3482 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 15:20:48 +02:00
bc9cfabc6b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 15:05:52 +02:00
81567d78d2 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 14:50:56 +02:00
a38a5d021b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 14:35:31 +02:00
49b21e5467 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 14:20:03 +02:00
13e626e5fe chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 14:04:49 +02:00
17688993c4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 13:49:23 +02:00
f758fabb8a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 13:34:11 +02:00
9a3232d743 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 13:19:29 +02:00
21f591815d chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 13:03:36 +02:00
0ad18ed6a6 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 12:48:33 +02:00
fc76a5f58f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 12:32:59 +02:00
c19fa40ca6 Scope update from repo-scoping refactor
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-05-01 12:26:13 +02:00
a12d5e0169 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 12:17:51 +02:00
b87bd2bca3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 12:02:40 +02:00
d9f3b12616 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 11:47:24 +02:00
bb1dfd919e chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 11:32:12 +02:00
e4250318e9 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 11:16:26 +02:00
4e4b2ecc19 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 11:01:19 +02:00
ba66031938 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 03:48:53 +02:00
b2093274e1 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 03:34:21 +02:00
0f3dba2c7b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 03:19:48 +02:00
caf93d8963 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 03:05:12 +02:00
c2e4bbd460 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 02:50:42 +02:00
0646e157f3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 02:36:11 +02:00
aec0491402 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 02:21:40 +02:00
0dd36811f6 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 02:07:16 +02:00
03499d16f1 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 01:53:07 +02:00
46a8438a04 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 01:38:48 +02:00
bb9024f738 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 01:23:34 +02:00
39ea81dbab chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 01:08:54 +02:00
ff1da9d8d3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 00:54:26 +02:00
b52e6a8536 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 00:39:43 +02:00
f1ed7f9fc2 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 00:25:09 +02:00
831e859f92 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for inter-hub
2026-05-01 00:10:31 +02:00
746e18a4ad chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 23:55:56 +02:00
442d7034a3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 23:41:23 +02:00
b9c3bb7f7b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 23:26:46 +02:00
d6de73ed61 fix(build): escape \${_k} as ''\${_k} in Nix ''...'' string
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Nix ''...'' strings interpolate \${...} — use ''$ to produce a literal
dollar sign so bash sees TypesPart\${_k}.hs not Nix interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 23:20:14 +02:00
5b144b6b96 fix(build): 8-way split + eliminate Generated.Types re-export hub
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Root cause: any module re-exporting all 119 IHP entities produces a .hi
file ≥ 287 MB, crashing GHC 9.10.3 Data.Binary.Get at exactly position
287,686,318 — even after 4-way split (30 entities × 9.6 MB/entity = 287 MB).

Fix:
- 8-way split of Generated.Types (~15 entities each, ~144 MB .hi — safe)
- Generated.Types replaced with empty stub, removed from exposed-modules
  (any re-export hub for 119 entities → ~1.1 GB .hi → crash downstream)
- pname == "inter-hub-lib" postUnpack patches all 148 `import Generated.Types`
  lines to import Generated.TypesPart1 through TypesPart8 directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 23:18:52 +02:00
ad4e195a01 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 23:12:10 +02:00
6d0350bd59 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 22:57:44 +02:00
c8c6c5c68b fix(build): 4-way split of Generated.Types to stay under 287 MB .hi limit
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2-way split (60 entities per part) still crashes: TypesPart1.hi itself
reaches 287 MB due to mandatory type-class instance data per IHP entity.
4-way split (~30 entities each, ~150 MB .hi) stays safely under the limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 22:52:10 +02:00
8ad2045dda chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 22:43:12 +02:00
1011557874 fix(build): configureFlags is a list, use ++ not + for -O0 flag
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-04-30 22:38:17 +02:00
c03badc9dd fix(build): force -O0 via configureFlags.ghc-option (cabal ghc-options not reliable in postUnpack) 2026-04-30 22:36:40 +02:00
4b269ab653 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 22:28:24 +02:00
607f93a634 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 22:13:35 +02:00
d104e02dbc chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 21:59:02 +02:00
662d8b156d chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 21:44:18 +02:00
d825bac641 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 21:29:39 +02:00
5be6302077 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 21:15:04 +02:00
5033485b9c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 21:00:40 +02:00
52ac9f64ea chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 20:45:46 +02:00
7cc722cbeb chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 20:31:08 +02:00
421408f369 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 20:16:39 +02:00
b38008ad94 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 20:01:41 +02:00
30954a966d chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 19:46:44 +02:00
c5179ce319 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 19:32:00 +02:00
a55c28bae4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 19:17:15 +02:00
e572baf0f7 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 19:02:32 +02:00
134c7ab6b7 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 18:48:12 +02:00
c0a00872d3 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 18:34:09 +02:00
8de11861d0 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 18:18:18 +02:00
c8b85b0249 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 18:03:31 +02:00
d5847a48fd chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 17:48:32 +02:00
e34c36c9ed chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 17:33:40 +02:00
ff7c25dcff chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 17:19:02 +02:00
0f73061d41 fix(build): add ghc-options -O0 to inter-hub-models — strips unfoldings from .hi files, keeps them under GHC 9.10.3 287MB binary limit
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-04-30 17:15:51 +02:00
6c8babf214 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 17:04:06 +02:00
df1d3fe118 fix(build): fix 8-space indent for TypesPart1/2 cabal entries (Cabal rejects 4-space)
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
2026-04-30 17:01:02 +02:00
9992623392 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 16:49:06 +02:00
1f828316d2 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 16:34:15 +02:00
ea10ec41d1 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 16:19:22 +02:00
269d5a5905 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 16:04:29 +02:00
0999921491 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 15:49:31 +02:00
2da69cebdf chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 15:34:37 +02:00
9cbfaef344 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 15:19:43 +02:00
cd1d06c6f8 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 15:04:56 +02:00
e1551d9bb7 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 14:49:50 +02:00
c5ae5530e2 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 14:34:52 +02:00
6e1531be2c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 14:19:55 +02:00
7432bd67d1 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 14:05:00 +02:00
2c9af08319 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 13:49:52 +02:00
223d3fe2b7 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 13:34:52 +02:00
2d44c8471a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 13:19:51 +02:00
3b5fbb3e22 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 13:04:49 +02:00
2653a78f39 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 12:49:44 +02:00
c02bf68139 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 12:34:39 +02:00
a9e249a09d chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 12:19:34 +02:00
e2e0e8ac3f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 12:04:30 +02:00
5d70289128 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 11:49:21 +02:00
2d5d507502 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 11:34:11 +02:00
a967b67f86 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 11:19:01 +02:00
f2dc802ee5 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 11:03:58 +02:00
1c24c67eed chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 10:48:43 +02:00
be47ff3dc8 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 10:33:27 +02:00
2659ddfeed chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 10:18:11 +02:00
684bf01b8c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 10:02:53 +02:00
ca31dc051f chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 09:47:27 +02:00
03420ec622 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 03:33:32 +02:00
77621a123b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 03:18:51 +02:00
5ed75ac0bd chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 03:04:06 +02:00
616f521d13 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 02:49:20 +02:00
619d2c7496 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 02:34:34 +02:00
39e650d89a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 02:19:52 +02:00
0bcb30246c chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 02:05:05 +02:00
cd21723be0 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 01:50:28 +02:00
e0dcdbc263 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 01:36:24 +02:00
c62b127bdb chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 01:21:09 +02:00
8d5905ba4a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 01:06:09 +02:00
ed4c97dbc4 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 00:51:21 +02:00
3283ad62ee fix(nix): intercept callCabal2nix to patch inter-hub-models
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Previous attempt failed: inter-hub-models is not a named attribute in
haskellPackages (IHP creates it via callCabal2nix locally), so the
hasAttr guard bailed silently.

New approach: override callCabal2nix itself. When called with
name == "inter-hub-models", inject a postUnpack phase that copies
TypesPart1/TypesPart2 into the build sandbox and replaces Types.hs
with the thin wrapper. Applied to both haskellPackages and
haskell.packages.ghc910 to cover whichever set IHP uses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:38:12 +02:00
4d788c2f8a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 00:36:55 +02:00
b018306f68 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 00:21:45 +02:00
f22bcc2993 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-30:
  - update .custodian-brief.md for inter-hub
2026-04-30 00:06:51 +02:00
51690c67d6 chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 23:52:04 +02:00
60aedb2e0a chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 23:37:23 +02:00
c6362066ac chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 23:22:28 +02:00
e52a2ba0e8 ci: add Gitea Actions workflow for build, push, and deploy
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Self-hosted runner on haskelseed. Pipeline: nix build .#docker →
skopeo push to Gitea registry → helm upgrade on Railiance01 → smoke test.

Runner setup required (one-time):
  - Register Gitea Actions runner on haskelseed with label "haskelseed"
  - Set secrets: GITEA_TOKEN (package:write scope), RAILIANCE01_KUBECONFIG
  - helm + kubectl in runner PATH (or via nix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 23:17:53 +02:00
9cbf4caadf fix(nix): fix GHC 9.10.3 interface-file crash and binary name
Generated.Types imports 119 modules, pushing the combined .hi read past
a ~287 MB binary-deserialization limit in GHC 9.10.3. Fix by adding a
nixpkgs overlay that patches the inter-hub-models derivation: replaces
Generated/Types.hs with a thin TypesPart1/TypesPart2 re-export wrapper
after build-generated-code runs, and adds the two split modules to the
cabal exposed-modules list.

Also fix the production binary name from /bin/App to /bin/RunProdServer
in deployment.yaml and RUNBOOK.md (the IHP NixSupport build produces
RunProdServer, not App). Switch packages.docker to IHP's built-in
unoptimized-docker-image which already uses the correct binary path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 23:16:44 +02:00
718fe3782b chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 23:07:37 +02:00
e549501744 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 21:13:42 +02:00
8534583573 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 20:59:10 +02:00
fdbfd8b1f4 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 20:44:35 +02:00
e57b7960d6 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 20:30:03 +02:00
8165e90573 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 20:15:29 +02:00
4e24c53188 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 20:00:56 +02:00
bef63aa14d chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 19:46:22 +02:00
c78d1cbf3b chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 19:31:53 +02:00
31a6700078 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 19:17:22 +02:00
d0373c0e27 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 19:02:55 +02:00
3fd6a33f45 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 18:48:27 +02:00
3801b809ce chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 18:33:47 +02:00
d830c386af chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 18:19:22 +02:00
0a72ee91ea feat(WP-0018/R6): Helm chart and runbook for Railiance01 deployment
Some checks failed
Test / test (push) Has been cancelled
Helm chart at deploy/helm/inter-hub/ with Deployment, Service, Ingress
(Traefik + letsencrypt-prod), and migration init container. Runbook at
deploy/railiance/RUNBOOK.md with build, push, rotate, rollback procedures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:06:44 +02:00
72bc145abd chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 18:04:58 +02:00
f802eb9198 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 17:50:27 +02:00
a25e1db5f8 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 17:36:10 +02:00
3fc99d17ec fix(WP-0018/R1): correct binary name in docker CMD to /bin/App
Some checks failed
Test / test (push) Has been cancelled
IHP cabal executable is named 'App' (not 'inter-hub'), matching the
executable declaration in App.cabal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:27:57 +02:00
452b1042a0 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 17:21:12 +02:00
ab5ecdcf9d chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 17:06:12 +02:00
0d68c667ed chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 16:51:39 +02:00
eeafba8077 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 16:37:12 +02:00
756f50ef46 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 16:22:29 +02:00
3ec6f9e0b1 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 16:08:52 +02:00
df181d1dec feat(WP-0018/R1): add OCI container image build to flake.nix
Some checks failed
Test / test (push) Has been cancelled
packages.docker using dockerTools.buildLayeredImage wraps the IHP
production binary with cacert for Anthropic API calls. Push target:
92.205.130.254:32166/coulomb/inter-hub:TAG via skopeo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:07:27 +02:00
35bd183a6d chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 15:53:24 +02:00
0edf05324e feat(WP-0018): workplan for Railiance01 deployment with full ops scaffold
Some checks failed
Test / test (push) Has been cancelled
OCI image build (Nix dockerTools), Helm chart in railiance-apps,
SOPS/age secrets, PostgreSQL HA on railiance-platform, Traefik ingress,
Gitea Actions CI/CD. Includes dependency gate on K3s cluster readiness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:50:24 +02:00
7c0ae6d17b chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 15:38:38 +02:00
12dfd5058e chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 15:23:57 +02:00
7598e26588 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 15:10:06 +02:00
1e2d473257 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 14:55:01 +02:00
750c9f25ff docs: add new-hub-quickstart.md — two-pattern domain hub guide
Some checks failed
Test / test (push) Has been cancelled
Covers Pattern A (API consumer, any language, start today) and Pattern B
(IHP extension hub, Haskell, shares haskelseed build infra). Includes honest
Haskell/IHP assessment, build-time estimates, hub-core sketch, and a
concrete checklist. References existing domain-hub-extension-guide.md for
type vocabulary registration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:53:19 +02:00
97294558e3 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 14:39:47 +02:00
abdfa8e034 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 14:24:58 +02:00
cc06bfdc90 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 14:10:01 +02:00
26bc67ff79 chore(consistency): sync task status from DB [auto]
Some checks failed
Test / test (push) Has been cancelled
Updated by fix-consistency on 2026-04-29:
  - update .custodian-brief.md for inter-hub
2026-04-29 13:55:02 +02:00
98 changed files with 9157 additions and 463 deletions

20
.claude/rules/agents.md Normal file
View 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.

View 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

View 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`

View 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 13 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 -->

View 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/
-->

View 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

View 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.

View 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)
```

View 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 -->

View File

@@ -0,0 +1 @@
{"sessionId":"7a72372c-e488-456c-baba-1e60d38649cf","pid":9468,"procStart":"127351","acquiredAt":1777404376811}

View File

@@ -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.

View 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
View 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
View 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
View 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/`)

View File

@@ -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

View File

@@ -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 ())

View File

@@ -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()

View 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;

View File

@@ -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
View File

@@ -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 112 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 2060 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 14 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 08, risks, design principles) |
| `specs/InteractionHubFrameworkSpecification_v0.2.md` | IHF extension spec (Phases 912) 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
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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))

View File

@@ -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)]

View File

@@ -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
]

View File

@@ -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 ''))"

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 &amp; 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>
|] |]

View File

@@ -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"

View File

@@ -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) }

View File

@@ -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

View File

@@ -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>

View File

@@ -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]

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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"

View File

@@ -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

View 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"

View 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

View 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 }}

View 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

View 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
View 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`)

View 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
View File

@@ -0,0 +1,6 @@
*
!.gitignore
!README.md
!*.example.yaml
!*.sops.yaml
!*.py

View 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`

View 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"

View 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

View 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)}")

View 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 ~1060 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 12 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: 2030 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.

View 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.

View 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>" }
}
}
}
```

View 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.

View 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
View 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 2040 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) | ~35 min |
| Incremental reload (1 module changed) | ~515 s |
| Adding a new controller+view pair | ~1030 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` |

View 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.

View 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?

View 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.

View File

@@ -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
View 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`.

View File

View File

@@ -0,0 +1,4 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
capabilities: []

View 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())

View 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.

View 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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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.

View 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`.

View 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.

View 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.

View 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.