Compare commits

322 Commits

Author SHA1 Message Date
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
76 changed files with 6869 additions and 442 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,38 @@
## First Session Protocol
Triggered when `get_domain_summary("custodian")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/custodian/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/custodian/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 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/inter-hub-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured custodian into N workstreams, M tasks",
event_type="milestone",
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

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:** custodian
**Repo slug:** inter-hub
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a

View File

@@ -0,0 +1,84 @@
## Session Protocol
State Hub: http://127.0.0.1:8000
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("custodian")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="inter-hub", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=inter-hub&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`todo`/`in_progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `custodian` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:inter-hub]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=inter-hub
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=inter-hub
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

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,28 @@
## Workplan Convention (ADR-001)
File location: `workplans/inter-hub-WP-NNNN-<slug>.md`
ID prefix: `INTER-WP`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-inter-hub-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:inter-hub]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

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

View File

@@ -2,7 +2,7 @@
# Custodian Brief — inter-hub
**Domain:** inter_hub
**Last synced:** 2026-04-29 11:40 UTC
**Last synced:** 2026-06-16 10:42 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Current Goal
@@ -11,36 +11,29 @@ IHF Phase 1 complete — Phase 2 ready to start
## Active Workstreams
### Autonomous Error-Fix Loop: Reach Clean Build
Progress: 0/5 done | workstream_id: `4636eb67-f7fb-409c-8d13-7fb461ef5db2`
### Personal Dashboard Framework
Progress: 0/4 done | workstream_id: `72fc022b-0196-492a-aaba-3475f8768f06`
**Open tasks:**
- · E1 — Start compile-check and capture initial error set `0ddc0559`
- · E2 — Fix Layer 2 errors (Application/Helper/*.hs) `2cd3dbb3`
- · E3 — Fix Layer 3 errors (Web/Controller/*.hs and Web/View/**/*.hs) `99c4345c`
- · E4 — Fix Layer 4 errors (Web/FrontController.hs, Web/Routes.hs) `c5dda487`
- · E5 — Commit clean build and close WP-0014/A1 `e20d48ea`
- · T01 — Research: Dashboard frameworks and patterns for inspiration `6074f195`
- · T02 — Product Requirements Specification (PRS) `698304bc`
- · T03 — Functional Design Document (FDD) `438e5771`
- · T04 — Implementation workplan `970aa221`
### Local Deployment — Intro and Tutorial Web UI
Progress: 0/7 done | workstream_id: `946d50b8-441c-4c0a-b1a0-2a4fb3340d16`
### Ops Hub Evidence Intake for Activity Core
Progress: 5/8 done | workstream_id: `bd086c41-287d-4a4e-8ac5-9ab270f14d72`
**Open tasks:**
- · B1 — Create StaticPages controller `e08a4e99`
- · B2 — Landing page view `2a2d4572`
- · B3 — Capabilities page view `112311bd`
- · B5 — Update root route to landing page `7dc59e27`
- · B4 — Tutorial and extension guide views `818c86ed`
- · B6 — Navigation integration `4155f30b`
- · B7 — Final deployment run and verification `8e8568b3`
- ! T03 - Prepare manifest vocabulary and seed widgets `94fc9806`
- ! T04 - Provision the runtime API key outside Git `267db6a7`
- ! T07 - Run end-to-end Inter-Hub submission smoke `23baee9b`
### Pre-flight: Close Deployment Gaps
Progress: 2/6 done | workstream_id: `532761e7-7c97-42e6-a5ea-59a972a80230`
### Ad hoc Inter-Hub production fixes
Progress: 0/1 done | workstream_id: `9e7a50b4-da7f-4df9-9154-7b89a071f520`
**Open tasks:**
- ► A2 — Fix compilation errors `40787dd7`
- · A3 — Enable Tailwind CSS build pipeline `45389d55`
- · A4 — Admin user seed migration `62a407f9`
- · A5 — Smoke test `326397bc`
- ! Fix COUNT decode failures in v2 bootstrap endpoints `cceee9f1`
*(wait: Image publication/deploy requires authenticated Gitea Actions workflow dispatch or inspection of the self-hosted haskelseed runner path; tags f8fde35 and 68c66b9 still return manifest unknown.)*
---
## MCP Orientation (when available)

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

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

162
AGENTS.md Normal file
View File

@@ -0,0 +1,162 @@
# Interaction Hub Framework — Agent Instructions
## Repo Identity
**Purpose:** Governed, observable interaction substrate for hub-based AI-enabled software systems (IHF specification and reference implementation).
**Domain:** custodian
**Repo slug:** inter-hub
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `INTER-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=inter-hub&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
# values: todo | in_progress | done | blocked
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=inter-hub&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check blocked tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=inter-hub
```
This syncs task status from files into the hub DB.
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/INTER-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-INTER-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: INTER-WP-NNNN
type: workplan
title: "..."
domain: custodian
repo: inter-hub
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: INTER-WP-NNNN-T01
status: todo | in_progress | done | blocked
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `in_progress` → `done` (or `blocked`)
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=inter-hub`
(or send a message to the hub agent via `POST /messages/`)

View File

@@ -27,7 +27,7 @@ checkRateLimitAndLog ::
checkRateLimitAndLog consumer endpoint method = do
-- Check rate limit: requests in last 60 seconds
rows1 <- sqlQuery
"SELECT COUNT(*) FROM api_request_log \
"SELECT COUNT(*)::int FROM api_request_log \
\WHERE api_consumer_id = ? AND requested_at >= NOW() - INTERVAL '60 seconds'"
(Only consumer.id)
let reqCount = case rows1 of
@@ -43,7 +43,7 @@ checkRateLimitAndLog consumer endpoint method = do
-- Check daily quota
rows2 <- sqlQuery
"SELECT COUNT(*) FROM api_request_log \
"SELECT COUNT(*)::int FROM api_request_log \
\WHERE api_consumer_id = ? AND requested_at >= ? - INTERVAL '1 day'"
(consumer.id, consumer.quotaResetsAt)
let quotaUsed = case rows2 of

View File

@@ -12,7 +12,7 @@ validateWidgetType ::
Text -> IO (Either Text ())
validateWidgetType name = do
rows <- sqlQuery
"SELECT COUNT(*) FROM widget_type_registry WHERE name = ? AND status = 'active'"
"SELECT COUNT(*)::int FROM widget_type_registry WHERE name = ? AND status = 'active'"
(Only name)
case rows of
[Only (n :: Int)] | n > 0 -> pure (Right ())
@@ -24,7 +24,7 @@ validateEventType ::
Text -> IO (Either Text ())
validateEventType name = do
rows <- sqlQuery
"SELECT COUNT(*) FROM event_type_registry WHERE name = ? AND status = 'active'"
"SELECT COUNT(*)::int FROM event_type_registry WHERE name = ? AND status = 'active'"
(Only name)
case rows of
[Only (n :: Int)] | n > 0 -> pure (Right ())
@@ -36,7 +36,7 @@ validateAnnotationCategory ::
Text -> IO (Either Text ())
validateAnnotationCategory name = do
rows <- sqlQuery
"SELECT COUNT(*) FROM annotation_category_registry WHERE name = ? AND status = 'active'"
"SELECT COUNT(*)::int FROM annotation_category_registry WHERE name = ? AND status = 'active'"
(Only name)
case rows of
[Only (n :: Int)] | n > 0 -> pure (Right ())
@@ -48,7 +48,7 @@ validatePolicyScope ::
Text -> IO (Either Text ())
validatePolicyScope name = do
rows <- sqlQuery
"SELECT COUNT(*) FROM policy_scope_registry WHERE name = ? AND status = 'active'"
"SELECT COUNT(*)::int FROM policy_scope_registry WHERE name = ? AND status = 'active'"
(Only name)
case rows of
[Only (n :: Int)] | n > 0 -> pure (Right ())

View File

@@ -1,6 +1,7 @@
-- Seed default admin user for initial local deployment.
-- Password: admin1234!
-- Hash generated with bcrypt cost 10 (compatible with IHP's authenticate @User).
-- Hash generated with pwstore-fast (Crypto.PasswordStore.makePassword, strength 17)
-- which is the format IHP's verifyPassword uses. NOT bcrypt.
-- IMPORTANT: Change this password immediately after first login via the profile settings.
-- Workplan: IHUB-WP-0014 (A4 — admin user seeding)
@@ -8,7 +9,7 @@ INSERT INTO users (id, email, password_hash, name, failed_login_attempts, create
VALUES (
uuid_generate_v4(),
'admin@inter-hub.local',
'$2b$10$c3imjL8nLkR1TSbBifvR3eFzlCUurGPXsN7K5trDjmZL6Af3zLqH.',
'sha256|17|hyVUQpp0hhegCg2oM0lUHQ==|jSwCi+tJUlKCW6sT6nn23/r71fd0GSiVOo48JSrXyWc=',
'Admin',
0,
now()

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,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
api_key TEXT,
hub_kind TEXT NOT NULL DEFAULT 'domain'
hub_kind TEXT NOT NULL DEFAULT 'domain',
hub_family TEXT,
vsm_function TEXT,
vsm_system TEXT,
CONSTRAINT hubs_vsm_metadata_consistency CHECK (
(hub_family IS NULL AND vsm_function IS NULL AND vsm_system IS NULL)
OR (
hub_family = 'vsm'
AND vsm_function IS NOT NULL
AND vsm_function <> ''
AND vsm_system IN ('1', '2', '3', '3*', '4', '5', 'environment')
)
)
);
-- Widgets — smallest semantically governable interaction units
@@ -557,6 +569,11 @@ CREATE INDEX hubs_hub_kind_idx ON hubs (hub_kind);
CREATE UNIQUE INDEX hubs_one_framework_idx ON hubs (hub_kind)
WHERE hub_kind = 'framework';
-- IHUB-WP-0019 T03 — first-class VSM hub metadata
CREATE INDEX hubs_hub_family_idx ON hubs (hub_family);
CREATE INDEX hubs_vsm_system_idx ON hubs (vsm_system)
WHERE vsm_system IS NOT NULL;
-- T03 — Type registries
CREATE TABLE widget_type_registry (

189
CLAUDE.md
View File

@@ -1,180 +1,11 @@
# CLAUDE.md
# Interaction Hub Framework — Claude Code Instructions
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**inter-hub** is the reference implementation of the **Interaction Hub Framework (IHF)** — a governed, observable interaction substrate for hub-based AI-enabled software systems. It treats every UI element as a governed artifact, creating a full traceability chain from rendered widget → user interaction → structured feedback → requirement candidate → decision record → implementation change → observed outcome.
**Current state:** Phases 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.
For situational context, read `SCOPE.md`. For architecture depth, read `specs/InteractionHubFrameworkSpecification_v0.1.md`.
## Stack
- **IHP** (Integrated Haskell Platform) v1.5 — full-stack Haskell web framework, server-rendered + optional realtime
- **Haskell** (GHC 9.10) — strongly typed, functional
- **PostgreSQL** — canonical datastore, managed via Nix (no manual DB setup)
- **Nix / devenv** — reproducible environment
- **Tailwind CSS** — see `specs/TailwindForInteractionHubs_v0.2.md` for IHF-specific conventions
## Development Setup
Requires Determinate Nix + direnv:
```bash
# One-time environment setup
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh
nix profile install nixpkgs#ihp-new
nix profile add nixpkgs#direnv
# Bootstrap IHP project (Phase 1, Task T01)
ihp-new inter-hub
cd inter-hub
devenv up
```
After `devenv up`:
- App server: `http://localhost:8000`
- IHP IDE + Schema Designer: `http://localhost:8001`
## Key Commands
```bash
devenv up # Start dev environment (app + postgres + file watchers)
migrate # Run pending migrations
test # Run tests (auto-creates temp Postgres DB)
make static/prod.js static/prod.css # Production asset bundle
deploy-to-nixos production # NixOS deploy
```
Schema editing: use the IHP IDE at `localhost:8001` or edit `Application/Schema.sql` directly. Code generation via `localhost:8001/Generators`.
## Compilation Layers
IHP compiles ~180 Haskell modules as one GHC target. Module changes are incremental — only changed modules and their dependents recompile. **Never modify Layer 1 during error-fix loops** — a change to `Web/Types.hs` or `Generated/Types.hs` invalidates all 59 controllers and 120 views simultaneously.
```
Layer 1 — stable core (compile once):
build/Generated/Types.hs IHP auto-generated from Schema.sql
Web/Types.hs controller type definitions
Layer 2 — helpers (only touch if Layer 1 is clean):
Application/Helper/*.hs 12 helper modules
Layer 3 — working surface (most errors live here):
Web/Controller/*.hs 59 controllers
Web/View/**/*.hs 120 views
Layer 4 — wiring (fix last):
Web/FrontController.hs
Web/Routes.hs
```
**Compile tools** (run inside `devenv shell`):
```bash
scripts/compile-check # full build via ghcid (all layers), log → /tmp/ihub-compile-errors.txt
scripts/compile-check --bg # same, background-friendly (no colour/title)
scripts/compile-check-core # Layer 1+2 only — verify clean base before touching Layer 3
```
**GHC compilation mode**: IHP uses `-fbyte-code` — GHCi compiles modules to bytecode in memory, not to persistent `.o` files. The `-fwrite-interface` flag writes `.hi` type-info files alongside source modules; these survive session restarts and skip re-type-checking of unchanged modules. Each ghcid restart regenerates bytecode for the full module graph. Expect 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
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/agents.md

426
HaskellVibePrimer.md Normal file
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/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
- Status: Phase 8 complete + GAAF compliance foundation complete (IHUB-WP-0009) — type registries, extension manifests, architectural contracts, and CI fitness functions in place; ready for Phase 9 (API versioning)
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard); Phase 7 complete (FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation, friction heatmap, bottleneck dashboard, hub health history, operational review board); Phase 8 complete (WidgetOwnership, HubRoutingRule, FederatedPolicyOverlay, StewardshipRole, ArchiveRecord, delegated ownership, inter-hub routing, federated governance dashboard, lineage inspector)
- Status: Phase 9 complete (IHUB-WP-0010) — type registries, extension manifests, architectural contracts, CI fitness functions, and versioned external API surface are in place.
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard); Phase 7 complete (FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation, friction heatmap, bottleneck dashboard, hub health history, operational review board); Phase 8 complete (WidgetOwnership, HubRoutingRule, FederatedPolicyOverlay, StewardshipRole, ArchiveRecord, delegated ownership, inter-hub routing, federated governance dashboard, lineage inspector); Phase 9 complete (versioned `/api/v2`, OpenAPI, API consumers and keys, OAuth client credentials, generated SDKs, webhooks, API usage dashboard, and rate limiting)
- Stability: core artifact model and schema are stable; Phase 6 contracts and Phase 8 activated policy overlays are immutable once active; native IHP widgets unaffected; Phase 7 observability scores are recomputed (not append-only), health snapshots are append-only; Phase 8 ownership records are soft-audit (no delete), archival is soft-delete (is_archived flag)
- Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start
@@ -88,7 +88,7 @@ IHF treats every meaningful UI element as a **governed interaction artifact** ra
---
## Related / Overlapping Repositories
## Related / Overlapping
- `the-custodian` — provides state-hub (decision records, workstreams) that IHF governance ledger will integrate with
- `ops-bridge` — tunnel connectivity for remote hub surfaces

View File

@@ -3,6 +3,21 @@ module Main where
import Test.Hspec
import IHP.Prelude
import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary
import Data.Aeson (Value(..), object, toJSON, (.=))
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import Web.Controller.Api.V2.InteractionEvents
( declaredEventTypeNames, manifestAllowsEvent, metadataFromJsonBody
, metadataParamOrEmpty
)
import Web.Controller.Api.V2.Hubs
( missingRequiredFields, validCreateHubKind, validVsmMetadata
, validVsmSystem )
import Web.Controller.Api.V2.HubCapabilityManifests
( jsonArrayTexts, textArrayFieldFromJsonBody )
import Web.Controller.Api.V2.ApiConsumers (positiveLimit)
import Web.Controller.Api.V2.OpenApi (buildPaths)
import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus)
main :: IO ()
main = hspec do
@@ -10,4 +25,110 @@ main = hspec do
it "should pass" do
1 + 1 `shouldBe` (2 :: Int)
describe "API v2 interaction-event manifest validation" do
let opsEventTypes = toJSON
( [ "ops-endpoint-verified"
, "ops-workflow-started"
] :: [Text]
)
it "decodes manifest-declared event types from JSON arrays" do
declaredEventTypeNames opsEventTypes
`shouldBe` ["ops-endpoint-verified", "ops-workflow-started"]
it "allows manifest-declared ops-owned domain events" do
manifestAllowsEvent "ops-endpoint-verified" opsEventTypes
`shouldBe` True
it "rejects events absent from an active manifest declaration" do
manifestAllowsEvent "clicked" opsEventTypes
`shouldBe` False
it "keeps empty declarations unrestricted for legacy manifests" do
manifestAllowsEvent "clicked" (toJSON ([] :: [Text]))
`shouldBe` True
it "preserves submitted metadata values and defaults missing metadata" do
let metadata = object ["source" .= ("ops-hub" :: Text)]
metadataFromJsonBody (object ["metadata" .= metadata]) `shouldBe` Just metadata
metadataParamOrEmpty (Just metadata) `shouldBe` metadata
metadataParamOrEmpty Nothing `shouldBe` object []
describe "API v2 hub and widget create validation" do
it "accepts scriptable domain/shared hub kinds only" do
validCreateHubKind "domain" `shouldBe` True
validCreateHubKind "shared" `shouldBe` True
validCreateHubKind "framework" `shouldBe` False
it "reports missing hub create fields including empty strings" do
missingRequiredFields
[ ("slug", Just "")
, ("name", Nothing)
, ("domain", Just "operations")
]
`shouldBe` ["slug", "name"]
it "accepts complete VSM hub classification for ops-hub" do
validVsmMetadata (Just "vsm") (Just "operations") (Just "1")
`shouldBe` True
validVsmSystem "1" `shouldBe` True
validVsmSystem "6" `shouldBe` False
it "rejects partial VSM metadata" do
validVsmMetadata (Just "vsm") (Just "operations") Nothing
`shouldBe` False
validVsmMetadata Nothing (Just "operations") (Just "1")
`shouldBe` False
it "accepts widget statuses supported by the UI create flow" do
validWidgetStatus "active" `shouldBe` True
validWidgetStatus "deprecated" `shouldBe` True
validWidgetStatus "draft" `shouldBe` True
validWidgetStatus "archived" `shouldBe` False
it "reports missing widget create fields including empty strings" do
missingWidgetCreateFields
[ ("hubId", Just "")
, ("name", Just "Ops endpoint card")
, ("widgetType", Nothing)
]
`shouldBe` ["hubId", "widgetType"]
describe "API v2 manifest vocabulary parsing" do
it "decodes declared vocabulary arrays from JSON request bodies" do
textArrayFieldFromJsonBody
"declaredPolicyScopes"
(object ["declaredPolicyScopes" .= (["ops-internal", "ops-external"] :: [Text])])
`shouldBe` Just ["ops-internal", "ops-external"]
it "extracts manifest-declared text arrays for activation" do
jsonArrayTexts (toJSON (["ops-endpoint-card", "ops-alert-panel"] :: [Text]))
`shouldBe` ["ops-endpoint-card", "ops-alert-panel"]
describe "API v2 API consumer bootstrap validation" do
it "requires positive rate-limit and quota values" do
positiveLimit 1 `shouldBe` True
positiveLimit 0 `shouldBe` False
positiveLimit (-1) `shouldBe` False
describe "API v2 OpenAPI auth contract" do
it "documents unauthenticated hub discovery for bootstrap clients" do
openApiOperationSecurity "/hubs" "get" buildPaths
`shouldBe` Just (toJSON ([] :: [Value]))
it "keeps hub creation authenticated" do
openApiOperationSecurity "/hubs" "post" buildPaths
`shouldBe` Just (toJSON [object ["BearerAuth" .= ([] :: [Text])]])
it "marks public vocabulary registries as unauthenticated" do
openApiOperationSecurity "/policy-scopes" "get" buildPaths
`shouldBe` Just (toJSON ([] :: [Value]))
LayerBoundary.spec
openApiOperationSecurity :: Text -> Text -> Value -> Maybe Value
openApiOperationSecurity path method (Object paths) = do
Object pathSpec <- KM.lookup (K.fromText path) paths
Object operation <- KM.lookup (K.fromText method) pathSpec
KM.lookup (K.fromText "security") operation
openApiOperationSecurity _ _ _ = Nothing

View File

@@ -10,10 +10,26 @@ import Web.Controller.Api.V2.Auth
, respondWithStatus )
import Application.Helper.TypeRegistry (validateAnnotationCategory)
import qualified Data.UUID as UUID
import Network.Wai (requestMethod)
instance Controller ApiV2AnnotationsController where
action ApiV2IndexAnnotationsAction = do
case requestMethod ?request of
"GET" -> listAnnotations
"POST" -> createApiAnnotation
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
action ApiV2ShowAnnotationAction { annotationId } = do
_consumer <- requireApiConsumer
ann <- fetch annotationId
renderJson (annotationToJson ann)
-- POST /api/v2/annotations
action ApiV2CreateAnnotationAction = createApiAnnotation
listAnnotations :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
listAnnotations = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
@@ -30,13 +46,8 @@ instance Controller ApiV2AnnotationsController where
anns <- q2 |> limit perPage |> offset off |> fetch
renderJson $ paginatedResponse (map annotationToJson anns) page perPage total
action ApiV2ShowAnnotationAction { annotationId } = do
_consumer <- requireApiConsumer
ann <- fetch annotationId
renderJson (annotationToJson ann)
-- POST /api/v2/annotations
action ApiV2CreateAnnotationAction = do
createApiAnnotation :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
createApiAnnotation = do
_consumer <- requireApiConsumer
let widgetIdText = paramOrNothing @Text "widgetId"
category = paramOrNothing @Text "category"
@@ -83,7 +94,7 @@ instance Controller ApiV2AnnotationsController where
|> set #body bodyTxt
|> set #actorType "api"
|> createRecord
renderJson (annotationToJson ann)
respondWithStatus 201 (annotationToJson ann)
annotationToJson :: Annotation -> Value
annotationToJson a = object

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
, "domain" .= hub.domain
, "hubKind" .= hub.hubKind
, "hubFamily" .= hub.hubFamily
, "vsmFunction" .= hub.vsmFunction
, "vsmSystem" .= hub.vsmSystem
, "gaafStatus" .= gaafIndicator
, "manifest" .= fmap manifestSummary mManifest
, "healthScore" .= fmap (.healthScore) mSnapshot

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 IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=))
import qualified Data.Text as T
import Data.Aeson (Value(..), object, (.=))
import IHP.ControllerSupport (getHeader, requestBodyJSON)
import Web.Controller.Api.V2.Auth
( requireApiConsumer, paginatedResponse, getPageParams
, respondWithStatus )
@@ -13,12 +13,33 @@ import Application.Helper.TypeRegistry (validateEventType)
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
import Control.Concurrent (forkIO)
import Control.Monad (void)
import Data.Maybe (fromMaybe, mapMaybe)
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBSC
import qualified Data.UUID as UUID
import qualified Data.Aeson as A
import qualified Data.Vector as V
import Network.Wai (requestMethod)
instance Controller ApiV2InteractionEventsController where
action ApiV2IndexInteractionEventsAction = do
case requestMethod ?request of
"GET" -> listInteractionEvents
"POST" -> createApiInteractionEvent
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
action ApiV2ShowInteractionEventAction { interactionEventId } = do
_consumer <- requireApiConsumer
event <- fetch interactionEventId
renderJson (eventToJson event)
-- POST /api/v2/interaction-events
action ApiV2CreateInteractionEventAction = createApiInteractionEvent
listInteractionEvents :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
listInteractionEvents = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let mWidgetId = paramOrNothing @(Id Widget) "widgetId"
@@ -36,14 +57,10 @@ instance Controller ApiV2InteractionEventsController where
events <- q2 |> limit perPage |> offset off |> fetch
renderJson $ paginatedResponse (map eventToJson events) page perPage total
action ApiV2ShowInteractionEventAction { interactionEventId } = do
_consumer <- requireApiConsumer
event <- fetch interactionEventId
renderJson (eventToJson event)
-- POST /api/v2/interaction-events
action ApiV2CreateInteractionEventAction = do
createApiInteractionEvent :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
createApiInteractionEvent = do
consumer <- requireApiConsumer
metadata <- metadataFromRequest
let widgetIdText = paramOrNothing @Text "widgetId"
eventType = paramOrNothing @Text "eventType"
viewContext = paramOrNothing @Text "viewContext"
@@ -76,9 +93,7 @@ instance Controller ApiV2InteractionEventsController where
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
manifest <- fetch manifestId
when (manifest.status == "active") do
let declared = case manifest.declaredEventTypes of
_ -> [] :: [Text] -- JSONB array decoded via aeson
unless (null declared || evType `elem` declared) do
unless (manifestAllowsEvent evType manifest.declaredEventTypes) do
respondWithStatus 422 $ object
[ "error" .= ("Event type not declared in hub manifest" :: Text)
, "code" .= ("event_type_not_in_manifest" :: Text)
@@ -100,6 +115,7 @@ instance Controller ApiV2InteractionEventsController where
|> set #eventType evType
|> set #actorType "api"
|> set #viewContextRef viewContext
|> set #metadata metadata
|> createRecord
-- Dispatch webhooks fire-and-forget
let webhookPayload = object
@@ -109,8 +125,8 @@ instance Controller ApiV2InteractionEventsController where
, "eventType" .= event.eventType
, "occurredAt" .= event.occurredAt
]
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload
renderJson (eventToJson event)
liftIO $ void $ forkIO $ dispatchWebhooks evType webhookPayload
respondWithStatus 201 (eventToJson event)
eventToJson :: InteractionEvent -> Value
eventToJson e = object
@@ -123,3 +139,34 @@ eventToJson e = object
, "metadata" .= e.metadata
, "occurredAt" .= e.occurredAt
]
declaredEventTypeNames :: A.Value -> [Text]
declaredEventTypeNames (Array values) = mapMaybe extractText (V.toList values)
where
extractText (String value) = Just value
extractText _ = Nothing
declaredEventTypeNames _ = []
manifestAllowsEvent :: Text -> A.Value -> Bool
manifestAllowsEvent eventType declaredEventTypes =
let declared = declaredEventTypeNames declaredEventTypes
in null declared || eventType `elem` declared
metadataParamOrEmpty :: Maybe A.Value -> A.Value
metadataParamOrEmpty = fromMaybe (object [])
metadataFromRequest :: (?context :: ControllerContext, ?request :: Request) => IO A.Value
metadataFromRequest =
case getHeader "Content-Type" of
Just contentType | "application/json" `BS.isPrefixOf` contentType -> do
body <- requestBodyJSON
pure $ metadataParamOrEmpty (metadataFromJsonBody body)
_ ->
pure $ metadataParamOrEmpty (metadataFromText =<< paramOrNothing @Text "metadata")
metadataFromJsonBody :: A.Value -> Maybe A.Value
metadataFromJsonBody (Object body) = KM.lookup "metadata" body
metadataFromJsonBody _ = Nothing
metadataFromText :: Text -> Maybe A.Value
metadataFromText raw = A.decode (LBSC.pack (cs raw))

View File

@@ -10,13 +10,15 @@ import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=), Array, toJSON)
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as K
import qualified Data.Vector as V
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Yaml as Yaml -- yaml package
import qualified Data.ByteString.Lazy as LBS
import Application.Helper.TypeRegistry
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories
, activePolicyScopes )
import Network.HTTP.Types (status200)
import Network.Wai (responseLBS)
@@ -47,10 +49,12 @@ buildOpenApiSpec = do
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
eventTypes <- activeEventTypes
annCats <- activeAnnotationCategories
policyScopes <- activePolicyScopes
let wtEnum = toJSON $ map (.name) allWidgetTypes
let etEnum = toJSON $ map (.name) eventTypes
let acEnum = toJSON $ map (.name) annCats
let psEnum = toJSON $ map (.name) policyScopes
pure $ object
[ "openapi" .= ("3.1.0" :: Text)
@@ -76,6 +80,10 @@ buildOpenApiSpec = do
[ "type" .= ("string" :: Text)
, "enum" .= acEnum
]
, "PolicyScope" .= object
[ "type" .= ("string" :: Text)
, "enum" .= psEnum
]
, "PaginationMeta" .= object
[ "type" .= ("object" :: Text)
, "properties" .= object
@@ -84,9 +92,22 @@ buildOpenApiSpec = do
, "total" .= object ["type" .= ("integer" :: Text)]
]
]
, "Hub" .= hubSchema
, "CreateHubRequest" .= createHubRequestSchema
, "HubCapabilityManifest" .= manifestSchema
, "CreateHubCapabilityManifestRequest" .= createManifestRequestSchema
, "UpdateHubCapabilityManifestRequest" .= updateManifestRequestSchema
, "ApiConsumer" .= apiConsumerSchema
, "CreateApiConsumerRequest" .= createApiConsumerRequestSchema
, "ApiKey" .= apiKeySchema
, "CreateApiKeyRequest" .= createApiKeyRequestSchema
, "ApiKeyCreatedResponse" .= apiKeyCreatedResponseSchema
, "Widget" .= widgetSchema
, "CreateWidgetRequest" .= createWidgetRequestSchema
, "InteractionEvent" .= interactionEventSchema
, "CreateInteractionEventRequest" .= createInteractionEventRequestSchema
, "Annotation" .= annotationSchema
, "CreateAnnotationRequest" .= createAnnotationRequestSchema
, "RequirementCandidate" .= rcSchema
, "DecisionRecord" .= drSchema
, "DeploymentRecord" .= depSchema
@@ -94,6 +115,12 @@ buildOpenApiSpec = do
, "OutcomeCorrelation" .= outcomeCorrelationSchema
, "PatternPerformanceRecord" .= patternPerformanceSchema
, "InstitutionalKnowledgeEntry" .= institutionalKnowledgeSchema
, "HubRegistryEntry" .= hubRegistryEntrySchema
, "HubManifestSummary" .= hubManifestSummarySchema
, "WidgetPattern" .= widgetPatternSchema
, "WidgetPatternDetail" .= widgetPatternDetailSchema
, "WidgetPatternVersion" .= widgetPatternVersionSchema
, "PatternAdoptionResponse" .= patternAdoptionResponseSchema
]
, "securitySchemes" .= object
[ "BearerAuth" .= object
@@ -108,7 +135,53 @@ buildOpenApiSpec = do
buildPaths :: Value
buildPaths = object
[ "/widgets" .= getListPath "Widget"
[ "/hubs" .= object
[ "get" .= publicPaginatedListOp "Hub" []
, "post" .= writeOp "Hub" "CreateHubRequest"
]
, "/hubs/{id}" .= getShowPath "Hub"
, "/hub-capability-manifests" .= object
[ "get" .= listOp "HubCapabilityManifest"
[ ("hubId", "string", "uuid")
, ("status", "string", "")
]
, "post" .= writeOp "HubCapabilityManifest" "CreateHubCapabilityManifestRequest"
]
, "/hub-capability-manifests/{id}" .= object
[ "get" .= showOp "HubCapabilityManifest"
, "patch" .= writeOpWithStatusAndParams
"Update HubCapabilityManifest"
"HubCapabilityManifest"
"UpdateHubCapabilityManifestRequest"
True
"200"
[pathParam "id"]
]
, "/hub-capability-manifests/{id}/activate" .= object
[ "post" .= postNoBodyOpWithStatusAndParams
"Activate HubCapabilityManifest"
"HubCapabilityManifest"
"200"
[pathParam "id"]
]
, "/api-consumers" .= object
[ "get" .= listOp "ApiConsumer" []
, "post" .= writeOp "ApiConsumer" "CreateApiConsumerRequest"
]
, "/api-consumers/{id}" .= getShowPath "ApiConsumer"
, "/api-consumers/{id}/api-keys" .= object
[ "post" .= writeOpWithResponseStatusAndParams
"Create ApiKey"
"ApiKeyCreatedResponse"
"CreateApiKeyRequest"
False
"201"
[pathParam "id"]
]
, "/widgets" .= object
[ "get" .= listOp "Widget" []
, "post" .= writeOp "Widget" "CreateWidgetRequest"
]
, "/widgets/{id}" .= getShowPath "Widget"
, "/interaction-events" .= object
[ "get" .= listOp "InteractionEvent"
@@ -135,14 +208,19 @@ buildPaths = object
, "/widget-types" .= publicListPath "WidgetTypeRegistry"
, "/event-types" .= publicListPath "EventTypeRegistry"
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
, "/policy-scopes" .= publicListPath "PolicyScopeRegistry"
, "/token" .= tokenPath
-- Phase 10 — Hub Registry and Widget Marketplace
, "/hub-registry" .= getListPath "HubRegistryEntry"
, "/hub-registry/{hubId}" .= getShowPath "HubRegistryEntry"
, "/hub-registry/{hubId}" .= getShowPathWithParam "HubRegistryEntry" "hubId"
, "/widget-patterns" .= getListPath "WidgetPattern"
, "/widget-patterns/{id}" .= getShowPath "WidgetPattern"
, "/widget-patterns/{id}" .= getShowPath "WidgetPatternDetail"
, "/widget-patterns/{id}/adopt" .= object
[ "post" .= writeOp "PatternAdoption" "AdoptPatternRequest"
[ "post" .= postNoBodyOpWithStatusAndParams
"Adopt WidgetPattern"
"PatternAdoptionResponse"
"200"
[pathParam "id"]
]
]
@@ -154,6 +232,10 @@ getShowPath :: Text -> Value
getShowPath schemaName = object
[ "get" .= showOp schemaName ]
getShowPathWithParam :: Text -> Text -> Value
getShowPathWithParam schemaName paramName = object
[ "get" .= showOpWithParam schemaName paramName ]
listOp :: Text -> [(Text, Text, Text)] -> Value
listOp schemaName extraParams = object
[ "summary" .= ("List " <> schemaName)
@@ -186,11 +268,45 @@ listOp schemaName extraParams = object
, "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else [])
]
publicPaginatedListOp :: Text -> [(Text, Text, Text)] -> Value
publicPaginatedListOp schemaName extraParams = object
[ "summary" .= ("List " <> schemaName)
, "security" .= ([] :: [Value])
, "parameters" .= (pageParams ++ map toParam extraParams)
, "responses" .= object
[ "200" .= object
[ "description" .= ("OK" :: Text)
, "content" .= object
[ "application/json" .= object
[ "schema" .= object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "data" .= object
[ "type" .= ("array" :: Text)
, "items" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]
]
, "meta" .= object ["$ref" .= ("#/components/schemas/PaginationMeta" :: Text)]
]
]
]
]
]
]
]
where
toParam (name, typ, fmt) = object $
[ "name" .= name, "in" .= ("query" :: Text)
, "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else [])
]
showOp :: Text -> Value
showOp schemaName = object
showOp schemaName = showOpWithParam schemaName "id"
showOpWithParam :: Text -> Text -> Value
showOpWithParam schemaName paramName = object
[ "summary" .= ("Get " <> schemaName)
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
, "parameters" .= [object ["name" .= ("id" :: Text), "in" .= ("path" :: Text), "required" .= True, "schema" .= object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]]]
, "parameters" .= [pathParam paramName]
, "responses" .= object
[ "200" .= object
[ "description" .= ("OK" :: Text)
@@ -205,27 +321,73 @@ showOp schemaName = object
]
writeOp :: Text -> Text -> Value
writeOp schemaName _reqSchema = object
[ "summary" .= ("Create " <> schemaName)
writeOp schemaName reqSchema = writeOpWithSummary ("Create " <> schemaName) schemaName reqSchema
writeOpWithSummary :: Text -> Text -> Text -> Value
writeOpWithSummary summaryText schemaName reqSchema =
writeOpWithStatusAndParams summaryText schemaName reqSchema True "201" []
writeOpWithStatusAndParams :: Text -> Text -> Text -> Bool -> Text -> [Value] -> Value
writeOpWithStatusAndParams = writeOpWithResponseStatusAndParams
writeOpWithResponseStatusAndParams :: Text -> Text -> Text -> Bool -> Text -> [Value] -> Value
writeOpWithResponseStatusAndParams summaryText responseSchema reqSchema bodyRequired successStatus params = object
[ "summary" .= summaryText
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
, "parameters" .= params
, "requestBody" .= object
[ "required" .= True
[ "required" .= bodyRequired
, "content" .= object
[ "application/json" .= object
["schema" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]]
["schema" .= object ["$ref" .= ("#/components/schemas/" <> reqSchema)]]
]
]
, "responses" .= object
[ "201" .= object ["description" .= ("Created" :: Text)]
[ K.fromText successStatus .= object
[ "description" .= ("OK" :: Text)
, "content" .= object
[ "application/json" .= object
["schema" .= object ["$ref" .= ("#/components/schemas/" <> responseSchema)]]
]
]
, "400" .= object ["description" .= ("Invalid request" :: Text)]
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
, "422" .= object ["description" .= ("Validation error" :: Text)]
]
]
postNoBodyOpWithStatusAndParams :: Text -> Text -> Text -> [Value] -> Value
postNoBodyOpWithStatusAndParams summaryText responseSchema successStatus params = object
[ "summary" .= summaryText
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
, "parameters" .= params
, "responses" .= object
[ K.fromText successStatus .= object
[ "description" .= ("OK" :: Text)
, "content" .= object
[ "application/json" .= object
["schema" .= object ["$ref" .= ("#/components/schemas/" <> responseSchema)]]
]
]
, "400" .= object ["description" .= ("Invalid request" :: Text)]
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
, "422" .= object ["description" .= ("Validation error" :: Text)]
]
]
pathParam :: Text -> Value
pathParam name = object
[ "name" .= name
, "in" .= ("path" :: Text)
, "required" .= True
, "schema" .= uuidProp
]
publicListPath :: Text -> Value
publicListPath schemaName = object
[ "get" .= object
[ "summary" .= ("List registered " <> schemaName <> " values" :: Text)
, "security" .= ([] :: [Value])
, "responses" .= object
[ "200" .= object ["description" .= ("OK" :: Text)] ]
]
@@ -266,6 +428,37 @@ pageParams =
-- Schemas for all resource types
hubSchema :: Value
hubSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "slug" .= strProp
, "name" .= strProp
, "domain" .= strProp
, "hubKind" .= object ["type" .= ("string" :: Text), "enum" .= ["domain" :: Text, "shared"]]
, "hubFamily" .= object ["type" .= ("string" :: Text), "enum" .= ["vsm" :: Text]]
, "vsmFunction" .= strProp
, "vsmSystem" .= object ["type" .= ("string" :: Text), "enum" .= ["1" :: Text, "2", "3", "3*", "4", "5", "environment"]]
, "createdAt" .= object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
]
]
createHubRequestSchema :: Value
createHubRequestSchema = object
[ "type" .= ("object" :: Text)
, "required" .= (["slug", "name", "domain"] :: [Text])
, "properties" .= object
[ "slug" .= strProp
, "name" .= strProp
, "domain" .= strProp
, "hubKind" .= object ["type" .= ("string" :: Text), "enum" .= ["domain" :: Text, "shared"]]
, "hubFamily" .= object ["type" .= ("string" :: Text), "enum" .= ["vsm" :: Text]]
, "vsmFunction" .= strProp
, "vsmSystem" .= object ["type" .= ("string" :: Text), "enum" .= ["1" :: Text, "2", "3", "3*", "4", "5", "environment"]]
]
]
widgetSchema :: Value
widgetSchema = object
[ "type" .= ("object" :: Text)
@@ -283,6 +476,135 @@ widgetSchema = object
]
]
createWidgetRequestSchema :: Value
createWidgetRequestSchema = object
[ "type" .= ("object" :: Text)
, "required" .= (["hubId", "name", "widgetType"] :: [Text])
, "properties" .= object
[ "hubId" .= uuidProp
, "name" .= strProp
, "widgetType" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]
, "capabilityRef" .= strProp
, "viewContext" .= strProp
, "policyScope" .= object ["$ref" .= ("#/components/schemas/PolicyScope" :: Text)]
, "status" .= object ["type" .= ("string" :: Text), "enum" .= ["active" :: Text, "deprecated", "draft"]]
, "adapterSpecId" .= uuidProp
]
]
manifestSchema :: Value
manifestSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "hubId" .= uuidProp
, "manifestVersion" .= strProp
, "declaredWidgetTypes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]]
, "declaredEventTypes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/EventType" :: Text)]]
, "declaredAnnotationCategories" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/AnnotationCategory" :: Text)]]
, "declaredPolicyScopes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/PolicyScope" :: Text)]]
, "capabilityDescription" .= strProp
, "contact" .= strProp
, "status" .= strProp
, "activatedAt" .= dtProp
, "createdAt" .= dtProp
, "updatedAt" .= dtProp
]
]
createManifestRequestSchema :: Value
createManifestRequestSchema = object
[ "type" .= ("object" :: Text)
, "required" .= (["hubId"] :: [Text])
, "properties" .= manifestRequestProperties True
]
updateManifestRequestSchema :: Value
updateManifestRequestSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= manifestRequestProperties False
]
manifestRequestProperties :: Bool -> Value
manifestRequestProperties includeHubId =
object $
(if includeHubId then ["hubId" .= uuidProp] else [])
++ [ "manifestVersion" .= strProp
, "declaredWidgetTypes" .= arrayOfRef "WidgetType"
, "declaredEventTypes" .= arrayOfRef "EventType"
, "declaredAnnotationCategories" .= arrayOfRef "AnnotationCategory"
, "declaredPolicyScopes" .= arrayOfRef "PolicyScope"
, "capabilityDescription" .= strProp
, "contact" .= strProp
]
apiConsumerSchema :: Value
apiConsumerSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "name" .= strProp
, "description" .= strProp
, "hubCapabilityManifestId" .= uuidProp
, "rateLimitPerMinute" .= object ["type" .= ("integer" :: Text)]
, "quotaPerDay" .= object ["type" .= ("integer" :: Text)]
, "quotaResetsAt" .= dtProp
, "isActive" .= object ["type" .= ("boolean" :: Text)]
, "createdAt" .= dtProp
, "updatedAt" .= dtProp
]
]
createApiConsumerRequestSchema :: Value
createApiConsumerRequestSchema = object
[ "type" .= ("object" :: Text)
, "required" .= (["name"] :: [Text])
, "properties" .= object
[ "name" .= strProp
, "description" .= strProp
, "hubCapabilityManifestId" .= uuidProp
, "rateLimitPerMinute" .= object ["type" .= ("integer" :: Text), "minimum" .= (1 :: Int), "default" .= (60 :: Int)]
, "quotaPerDay" .= object ["type" .= ("integer" :: Text), "minimum" .= (1 :: Int), "default" .= (10000 :: Int)]
]
]
apiKeySchema :: Value
apiKeySchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "apiConsumerId" .= uuidProp
, "keyPrefix" .= strProp
, "scopes" .= strProp
, "tokenType" .= strProp
, "expiresAt" .= dtProp
, "revokedAt" .= dtProp
, "lastUsedAt" .= dtProp
, "createdAt" .= dtProp
]
]
createApiKeyRequestSchema :: Value
createApiKeyRequestSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "scopes" .= strProp
]
]
apiKeyCreatedResponseSchema :: Value
apiKeyCreatedResponseSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "apiKey" .= object ["$ref" .= ("#/components/schemas/ApiKey" :: Text)]
, "fullKey" .= object
[ "type" .= ("string" :: Text)
, "description" .= ("Static API key secret. Returned only in this creation response; it is stored hashed and cannot be recovered later." :: Text)
]
, "displayOnce" .= boolProp
]
]
interactionEventSchema :: Value
interactionEventSchema = object
[ "type" .= ("object" :: Text)
@@ -298,6 +620,18 @@ interactionEventSchema = object
]
]
createInteractionEventRequestSchema :: Value
createInteractionEventRequestSchema = object
[ "type" .= ("object" :: Text)
, "required" .= (["widgetId", "eventType"] :: [Text])
, "properties" .= object
[ "widgetId" .= uuidProp
, "eventType" .= object ["$ref" .= ("#/components/schemas/EventType" :: Text)]
, "viewContext" .= strProp
, "metadata" .= objectProp
]
]
annotationSchema :: Value
annotationSchema = object
[ "type" .= ("object" :: Text)
@@ -315,6 +649,17 @@ annotationSchema = object
]
]
createAnnotationRequestSchema :: Value
createAnnotationRequestSchema = object
[ "type" .= ("object" :: Text)
, "required" .= (["widgetId", "category", "body"] :: [Text])
, "properties" .= object
[ "widgetId" .= uuidProp
, "category" .= object ["$ref" .= ("#/components/schemas/AnnotationCategory" :: Text)]
, "body" .= strProp
]
]
rcSchema :: Value
rcSchema = object
[ "type" .= ("object" :: Text)
@@ -416,12 +761,114 @@ institutionalKnowledgeSchema = object
]
]
hubRegistryEntrySchema :: Value
hubRegistryEntrySchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "name" .= strProp
, "slug" .= strProp
, "domain" .= strProp
, "hubKind" .= strProp
, "hubFamily" .= strProp
, "vsmFunction" .= strProp
, "vsmSystem" .= strProp
, "gaafStatus" .= object ["type" .= ("string" :: Text), "enum" .= ["compliant" :: Text, "draft_only", "no_manifest"]]
, "manifest" .= object ["$ref" .= ("#/components/schemas/HubManifestSummary" :: Text)]
, "healthScore" .= intProp
, "healthAt" .= dtProp
]
]
hubManifestSummarySchema :: Value
hubManifestSummarySchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "manifestVersion" .= strProp
, "status" .= strProp
, "declaredWidgetTypes" .= arrayOfRef "WidgetType"
, "declaredEventTypes" .= arrayOfRef "EventType"
, "declaredAnnotationCategories" .= arrayOfRef "AnnotationCategory"
, "declaredPolicyScopes" .= arrayOfRef "PolicyScope"
, "activatedAt" .= dtProp
]
]
widgetPatternSchema :: Value
widgetPatternSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "hubId" .= uuidProp
, "name" .= strProp
, "description" .= strProp
, "widgetType" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]
, "isCrossHub" .= boolProp
, "isPublished" .= boolProp
, "createdAt" .= dtProp
, "updatedAt" .= dtProp
]
]
widgetPatternDetailSchema :: Value
widgetPatternDetailSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "pattern" .= object ["$ref" .= ("#/components/schemas/WidgetPattern" :: Text)]
, "versions" .= object
[ "type" .= ("array" :: Text)
, "items" .= object ["$ref" .= ("#/components/schemas/WidgetPatternVersion" :: Text)]
]
, "adopterCount" .= intProp
]
]
widgetPatternVersionSchema :: Value
widgetPatternVersionSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "versionNumber" .= intProp
, "definition" .= objectProp
, "changelog" .= strProp
, "publishedAt" .= dtProp
]
]
patternAdoptionResponseSchema :: Value
patternAdoptionResponseSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "adopted" .= boolProp
, "adoptionId" .= uuidProp
]
]
uuidProp :: Value
uuidProp = object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
strProp :: Value
strProp = object ["type" .= ("string" :: Text)]
intProp :: Value
intProp = object ["type" .= ("integer" :: Text)]
boolProp :: Value
boolProp = object ["type" .= ("boolean" :: Text)]
objectProp :: Value
objectProp = object
[ "type" .= ("object" :: Text)
, "additionalProperties" .= True
]
arrayOfRef :: Text -> Value
arrayOfRef schemaName = object
[ "type" .= ("array" :: Text)
, "items" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]
]
dtProp :: Value
dtProp = object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]

View File

@@ -4,6 +4,7 @@ module Web.Controller.Api.V2.Registries where
-- GET /api/v2/widget-types
-- GET /api/v2/event-types
-- GET /api/v2/annotation-categories
-- GET /api/v2/policy-scopes
import Web.Types
import Generated.Types
@@ -16,24 +17,31 @@ instance Controller ApiV2RegistriesController where
action ApiV2ListWidgetTypesAction = do
types <- query @WidgetTypeRegistry
|> filterWhere (#status, "active")
|> orderByAsc #label_
|> orderByAsc #name
|> fetch
renderJson $ map wtToJson types
action ApiV2ListEventTypesAction = do
types <- query @EventTypeRegistry
|> filterWhere (#status, "active")
|> orderByAsc #label_
|> orderByAsc #name
|> fetch
renderJson $ map etToJson types
action ApiV2ListAnnotationCategoriesAction = do
cats <- query @AnnotationCategoryRegistry
|> filterWhere (#status, "active")
|> orderByAsc #label_
|> orderByAsc #name
|> fetch
renderJson $ map acToJson cats
action ApiV2ListPolicyScopesAction = do
scopes <- query @PolicyScopeRegistry
|> filterWhere (#status, "active")
|> orderByAsc #name
|> fetch
renderJson $ map psToJson scopes
wtToJson :: WidgetTypeRegistry -> Value
wtToJson r = object
[ "name" .= r.name
@@ -60,3 +68,12 @@ acToJson r = object
, "ownerHubId" .= r.ownerHubId
, "status" .= r.status
]
psToJson :: PolicyScopeRegistry -> Value
psToJson r = object
[ "name" .= r.name
, "label" .= r.label_
, "description" .= r.description
, "ownerHubId" .= r.ownerHubId
, "status" .= r.status
]

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 }) {"
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
, " return this.fetch('/widgets' + q).then(r => r.json());"
, " }"
, ""
, " async createWidget(body: { hubId: string; name: string; widgetType: WidgetType; capabilityRef?: string; viewContext?: string; policyScope?: string; status?: 'active' | 'deprecated' | 'draft' }) {"
, " return this.fetch('/widgets', 'POST', body).then(r => r.json());"
, " }"
, ""
, " async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) {"
, " const qs = new URLSearchParams();"
, " if (params?.widgetId) qs.set('widgetId', params.widgetId);"
@@ -149,9 +177,46 @@ pyClientClass = T.unlines
, " with urllib.request.urlopen(req) as resp:"
, " return json.loads(resp.read())"
, ""
, " def create_hub(self, slug: str, name: str, domain: str, hub_kind: str = 'domain', hub_family: Optional[str] = None, vsm_function: Optional[str] = None, vsm_system: Optional[str] = None) -> dict:"
, " body: dict = {'slug': slug, 'name': name, 'domain': domain, 'hubKind': hub_kind}"
, " if hub_family: body['hubFamily'] = hub_family"
, " if vsm_function: body['vsmFunction'] = vsm_function"
, " if vsm_system: body['vsmSystem'] = vsm_system"
, " return self._request('/hubs', 'POST', body)"
, ""
, " def create_hub_capability_manifest(self, body: dict) -> dict:"
, " return self._request('/hub-capability-manifests', 'POST', body)"
, ""
, " def update_hub_capability_manifest(self, manifest_id: str, body: dict) -> dict:"
, " return self._request('/hub-capability-manifests/' + manifest_id, 'PATCH', body)"
, ""
, " def activate_hub_capability_manifest(self, manifest_id: str) -> dict:"
, " return self._request('/hub-capability-manifests/' + manifest_id + '/activate', 'POST')"
, ""
, " def create_api_consumer(self, name: str, description: Optional[str] = None, hub_capability_manifest_id: Optional[str] = None, rate_limit_per_minute: Optional[int] = None, quota_per_day: Optional[int] = None) -> dict:"
, " body: dict = {'name': name}"
, " if description: body['description'] = description"
, " if hub_capability_manifest_id: body['hubCapabilityManifestId'] = hub_capability_manifest_id"
, " if rate_limit_per_minute: body['rateLimitPerMinute'] = rate_limit_per_minute"
, " if quota_per_day: body['quotaPerDay'] = quota_per_day"
, " return self._request('/api-consumers', 'POST', body)"
, ""
, " def create_api_key(self, api_consumer_id: str, scopes: Optional[str] = None) -> dict:"
, " body: dict = {}"
, " if scopes: body['scopes'] = scopes"
, " return self._request('/api-consumers/' + api_consumer_id + '/api-keys', 'POST', body)"
, ""
, " def get_widgets(self, page: int = 1, per_page: int = 50) -> dict:"
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
, ""
, " def create_widget(self, hub_id: str, name: str, widget_type: WidgetType, capability_ref: Optional[str] = None, view_context: Optional[str] = None, policy_scope: Optional[str] = None, status: Optional[str] = None) -> dict:"
, " body: dict = {'hubId': hub_id, 'name': name, 'widgetType': str(widget_type)}"
, " if capability_ref: body['capabilityRef'] = capability_ref"
, " if view_context: body['viewContext'] = view_context"
, " if policy_scope: body['policyScope'] = policy_scope"
, " if status: body['status'] = status"
, " return self._request('/widgets', 'POST', body)"
, ""
, " def get_interaction_events(self, widget_id: Optional[str] = None, event_type: Optional[EventType] = None) -> dict:"
, " qs = urllib.parse.urlencode({k: v for k, v in {'widgetId': widget_id, 'eventType': event_type and str(event_type)}.items() if v})"
, " return self._request('/interaction-events' + ('?' + qs if qs else ''))"

View File

@@ -4,12 +4,31 @@ import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=), ToJSON, toJSON)
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
import Data.Aeson (Value, object, (.=))
import Network.Wai (requestMethod)
import qualified Data.UUID as UUID
import Application.Helper.TypeRegistry (validateWidgetType, validatePolicyScope)
import Web.Controller.Api.V2.Auth
( requireApiConsumer, paginatedResponse, getPageParams
, respondWithStatus )
instance Controller ApiV2WidgetsController where
action ApiV2IndexWidgetsAction = do
case requestMethod ?request of
"GET" -> listWidgets
"POST" -> createApiWidget
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
action ApiV2ShowWidgetAction { widgetId } = do
_consumer <- requireApiConsumer
widget <- fetch widgetId
renderJson (widgetToJson widget)
action ApiV2CreateWidgetAction = createApiWidget
listWidgets :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
listWidgets = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let pageOffset = (page - 1) * perPage
@@ -21,10 +40,121 @@ instance Controller ApiV2WidgetsController where
|> fetch
renderJson $ paginatedResponse (map widgetToJson widgets) page perPage total
action ApiV2ShowWidgetAction { widgetId } = do
createApiWidget :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
createApiWidget = do
_consumer <- requireApiConsumer
widget <- fetch widgetId
renderJson (widgetToJson widget)
let hubIdText = paramOrNothing @Text "hubId"
name = paramOrNothing @Text "name"
widgetType = paramOrNothing @Text "widgetType"
capabilityRef = paramOrNothing @Text "capabilityRef"
viewContext = paramOrNothing @Text "viewContext"
policyScope = fromMaybe "internal" (nonEmptyText =<< paramOrNothing @Text "policyScope")
status = fromMaybe "active" (nonEmptyText =<< paramOrNothing @Text "status")
let missing = missingWidgetCreateFields
[ ("hubId", hubIdText)
, ("name", name)
, ("widgetType", widgetType)
]
unless (null missing) do
respondWithStatus 422 $ object
[ "error" .= ("Missing required fields" :: Text)
, "missing" .= missing
]
unless (validWidgetStatus status) do
respondWithStatus 422 $ object
[ "error" .= ("Unsupported widget status" :: Text)
, "code" .= ("unsupported_widget_status" :: Text)
, "value" .= status
, "valid" .= validWidgetStatuses
]
let Just hubIdRaw = hubIdText
Just nameText = name
Just typeText = widgetType
typeResult <- liftIO $ validateWidgetType typeText
case typeResult of
Left _ -> respondWithStatus 422 $ object
[ "error" .= ("Unregistered widget type" :: Text)
, "code" .= ("unregistered_widget_type" :: Text)
, "value" .= typeText
, "registry" .= ("/api/v2/widget-types" :: Text)
]
Right () -> pure ()
scopeResult <- liftIO $ validatePolicyScope policyScope
case scopeResult of
Left _ -> respondWithStatus 422 $ object
[ "error" .= ("Unregistered policy scope" :: Text)
, "code" .= ("unregistered_policy_scope" :: Text)
, "value" .= policyScope
, "registry" .= ("/api/v2/policy-scopes" :: Text)
]
Right () -> pure ()
adapterSpecId <- parseOptionalAdapterSpecId
case UUID.fromText hubIdRaw of
Nothing -> respondWithStatus 422 $ object
["error" .= ("hubId must be a valid UUID" :: Text)]
Just rawId -> do
let hubId = Id rawId :: Id Hub
mHub <- fetchOneOrNothing hubId
case mHub of
Nothing -> respondWithStatus 422 $ object
["error" .= ("Hub not found" :: Text)]
Just _hub -> do
widget <- newRecord @Widget
|> set #hubId hubId
|> set #name nameText
|> set #widgetType typeText
|> set #capabilityRef capabilityRef
|> set #viewContext viewContext
|> set #policyScope policyScope
|> set #status status
|> set #adapterSpecId adapterSpecId
|> createRecord
_version <- createInitialWidgetVersion widget
respondWithStatus 201 (widgetToJson widget)
parseOptionalAdapterSpecId :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO (Maybe (Id WidgetAdapterSpec))
parseOptionalAdapterSpecId =
case paramOrNothing @Text "adapterSpecId" of
Nothing -> pure Nothing
Just "" -> pure Nothing
Just adapterSpecRaw ->
case UUID.fromText adapterSpecRaw of
Nothing -> respondWithStatus 422 $ object
["error" .= ("adapterSpecId must be a valid UUID" :: Text)]
Just rawId -> do
let adapterSpecId = Id rawId :: Id WidgetAdapterSpec
mAdapterSpec <- fetchOneOrNothing adapterSpecId
case mAdapterSpec of
Nothing -> respondWithStatus 422 $ object
["error" .= ("Widget adapter spec not found" :: Text)]
Just _ -> pure (Just adapterSpecId)
createInitialWidgetVersion :: (?modelContext :: ModelContext) => Widget -> IO WidgetVersion
createInitialWidgetVersion widget =
newRecord @WidgetVersion
|> set #widgetId widget.id
|> set #version 1
|> set #schemaSnapshot (widgetVersionSnapshot widget)
|> createRecord
widgetVersionSnapshot :: Widget -> Value
widgetVersionSnapshot widget = object
[ "name" .= widget.name
, "widget_type" .= widget.widgetType
, "hub_id" .= widget.hubId
, "capability_ref" .= widget.capabilityRef
, "view_context" .= widget.viewContext
, "policy_scope" .= widget.policyScope
, "status" .= widget.status
, "version" .= widget.version
]
widgetToJson :: Widget -> Value
widgetToJson w = object
@@ -39,3 +169,17 @@ widgetToJson w = object
, "version" .= w.version
, "createdAt" .= w.createdAt
]
validWidgetStatuses :: [Text]
validWidgetStatuses = ["active", "deprecated", "draft"]
validWidgetStatus :: Text -> Bool
validWidgetStatus status = status `elem` validWidgetStatuses
missingWidgetCreateFields :: [(Text, Maybe Text)] -> [Text]
missingWidgetCreateFields fields =
[ name | (name, value) <- fields, maybe True (== "") value ]
nonEmptyText :: Text -> Maybe Text
nonEmptyText "" = Nothing
nonEmptyText value = Just value

View File

@@ -16,7 +16,7 @@ instance Controller TypeRegistriesController where
action WidgetTypeRegistryAction = do
entries <- query @WidgetTypeRegistry
|> orderByAsc #label_
|> orderByAsc #name
|> fetch
hubs <- query @Hub |> fetch
render WidgetTypesView { entries, hubs }
@@ -83,7 +83,7 @@ instance Controller TypeRegistriesController where
action EventTypeRegistryAction = do
entries <- query @EventTypeRegistry
|> orderByAsc #label_
|> orderByAsc #name
|> fetch
hubs <- query @Hub |> fetch
render EventTypesView { entries, hubs }
@@ -149,7 +149,7 @@ instance Controller TypeRegistriesController where
action AnnotationCategoryRegistryAction = do
entries <- query @AnnotationCategoryRegistry
|> orderByAsc #label_
|> orderByAsc #name
|> fetch
hubs <- query @Hub |> fetch
render AnnotationCategoriesView { entries, hubs }
@@ -215,7 +215,7 @@ instance Controller TypeRegistriesController where
action PolicyScopeRegistryAction = do
entries <- query @PolicyScopeRegistry
|> orderByAsc #label_
|> orderByAsc #name
|> fetch
hubs <- query @Hub |> fetch
render PolicyScopesView { entries, hubs }

View File

@@ -48,6 +48,9 @@ import Web.Controller.Api.V2.Registries ()
import Web.Controller.Api.V2.OpenApi ()
import Web.Controller.Api.V2.Token ()
import Web.Controller.Api.V2.Sdk ()
import Web.Controller.Api.V2.Hubs ()
import Web.Controller.Api.V2.HubCapabilityManifests ()
import Web.Controller.Api.V2.ApiConsumers ()
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
import Web.Controller.HubRegistry ()
import Web.Controller.WidgetPatterns ()
@@ -116,6 +119,9 @@ instance FrontController WebApplication where
, parseRoute @ApiV2OpenApiController
, parseRoute @ApiV2TokenController
, parseRoute @ApiV2SdkController
, parseRoute @ApiV2HubsController
, parseRoute @ApiV2HubCapabilityManifestsController
, parseRoute @ApiV2ApiConsumersController
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
, parseRoute @HubRegistryController
, parseRoute @WidgetPatternsController
@@ -147,7 +153,19 @@ instance InitControllerContext WebApplication where
initAuthentication @User
defaultLayout :: (?context :: ControllerContext, ?request :: Request) => Layout
defaultLayout inner = [hsx|
defaultLayout inner =
let authWidget :: Html
authWidget = case currentUserOrNothing @User of
Just _ -> [hsx|
<form method="POST" action={DeleteSessionAction} style="display:inline">
<input type="hidden" name="_method" value="DELETE" />
<button type="submit" class="text-sm text-gray-500 hover:text-gray-700 bg-transparent border-0 p-0 cursor-pointer">Sign out</button>
</form>
|]
Nothing -> [hsx|
<a href={NewSessionAction} class="text-sm text-gray-500 hover:text-gray-900">Sign in</a>
|]
in [hsx|
<!DOCTYPE html>
<html lang="en">
<head>
@@ -160,44 +178,59 @@ defaultLayout inner = [hsx|
<script src="/vendor/ihp-auto-refresh.js"></script>
<script src="/js/ihf-annotation-launcher.js"></script>
</head>
<body class="bg-gray-50 text-gray-900">
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
<body class="bg-gray-50 text-gray-900" style="min-height:100vh;display:flex;flex-direction:column">
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center">
<a href={LandingAction} class="font-semibold text-indigo-600">inter-hub</a>
<a href={CapabilitiesAction} class="text-sm text-gray-600 hover:text-gray-900">About</a>
<a href={TutorialAction} class="text-sm text-gray-600 hover:text-gray-900">Tutorial</a>
<a href={ExtensionGuideAction} class="text-sm text-gray-600 hover:text-gray-900">Extend</a>
<div class="ml-auto flex items-center" style="gap:2rem">
<div class="flex items-center" style="gap:1.75rem">
<a href={CapabilitiesAction} class="text-sm text-gray-500 hover:text-gray-900">About</a>
<a href={TutorialAction} class="text-sm text-gray-500 hover:text-gray-900">Tutorial</a>
<a href={ExtensionGuideAction} class="text-sm text-gray-500 hover:text-gray-900">Extend</a>
</div>
<span class="text-gray-200">|</span>
<a href={HubsAction} class="text-sm text-gray-600 hover:text-gray-900">Hubs</a>
<a href={WidgetsAction} class="text-sm text-gray-600 hover:text-gray-900">Widgets</a>
<a href={RequirementCandidatesAction} class="text-sm text-gray-600 hover:text-gray-900">Candidates</a>
<a href={RequirementsAction} class="text-sm text-gray-600 hover:text-gray-900">Requirements</a>
<a href={DecisionRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Decisions</a>
<a href={DeploymentRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Deployments</a>
<a href={AgentProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Agent</a>
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-600 hover:text-gray-900">Adapters</a>
<a href={CrossHubPropagationsAction} class="text-sm text-gray-600 hover:text-gray-900">Propagations</a>
<a href={OperationalReviewBoardAction} class="text-sm text-gray-600 hover:text-gray-900">Ops Review</a>
<a href={FederatedGovernanceDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Federation</a>
<a href={FederatedPolicyOverlaysAction} class="text-sm text-gray-600 hover:text-gray-900">Policies</a>
<a href={ArchiveRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Archive</a>
<a href={WidgetTypeRegistryAction} class="text-sm text-gray-600 hover:text-gray-900">Registries</a>
<a href={HubCapabilityManifestsAction} class="text-sm text-gray-600 hover:text-gray-900">Extensions</a>
<a href={ApiConsumersAction} class="text-sm text-gray-600 hover:text-gray-900">API</a>
<a href={ShowApiDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">API Dashboard</a>
<a href={HubRegistryAction} class="text-sm text-gray-600 hover:text-gray-900">Hub Registry</a>
<a href={MarketplaceDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Marketplace</a>
<a href={AgentRegistrationsAction} class="text-sm text-gray-600 hover:text-gray-900">Agents</a>
<a href={ModelRoutingPoliciesAction} class="text-sm text-gray-600 hover:text-gray-900">Routing</a>
<a href={CollectiveProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Collective</a>
<a href={AiGovernancePoliciesAction} class="text-sm text-gray-600 hover:text-gray-900">AI Gov</a>
<a href={LearningDashboardAction} class="text-sm text-gray-600 hover:text-gray-900">Learning</a>
<div class="ml-auto">
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
{authWidget}
</div>
</nav>
<main class="max-w-5xl mx-auto px-6 py-8">
<div class="flex" style="flex:1">
<aside class="w-48 bg-white border-r border-gray-200 flex-shrink-0 overflow-y-auto">
<nav class="px-3 py-4 space-y-0.5">
<a href={HubsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Hubs</a>
<a href={WidgetsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Widgets</a>
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Governance</div>
<a href={RequirementCandidatesAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Candidates</a>
<a href={RequirementsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Requirements</a>
<a href={DecisionRecordsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Decisions</a>
<a href={DeploymentRecordsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Deployments</a>
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Intelligence</div>
<a href={AgentProposalsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Agent Proposals</a>
<a href={AgentRegistrationsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Agents</a>
<a href={ModelRoutingPoliciesAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Routing</a>
<a href={CollectiveProposalsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Collective</a>
<a href={AiGovernancePoliciesAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">AI Gov</a>
<a href={LearningDashboardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Learning</a>
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Platform</div>
<a href={WidgetAdapterSpecsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Adapters</a>
<a href={CrossHubPropagationsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Propagations</a>
<a href={OperationalReviewBoardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Ops Review</a>
<a href={FederatedGovernanceDashboardAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Federation</a>
<a href={FederatedPolicyOverlaysAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Policies</a>
<a href={ArchiveRecordsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Archive</a>
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Registry</div>
<a href={WidgetTypeRegistryAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Registries</a>
<a href={HubCapabilityManifestsAction} class="block px-3 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100">Extensions</a>
<div class="pt-4 pb-1 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">API &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>
</html>
|]

View File

@@ -89,6 +89,7 @@ instance CanRoute ApiV2WidgetsController where
instance HasPath ApiV2WidgetsController where
pathTo ApiV2IndexWidgetsAction = "/api/v2/widgets"
pathTo ApiV2ShowWidgetAction { widgetId } = "/api/v2/widgets/" <> tshow widgetId
pathTo ApiV2CreateWidgetAction = "/api/v2/widgets"
instance CanRoute ApiV2InteractionEventsController where
parseRoute' = do
@@ -177,12 +178,14 @@ instance CanRoute ApiV2RegistriesController where
[ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction
, do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction
, do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction
, do _ <- string "policy-scopes"; endOfInput; pure ApiV2ListPolicyScopesAction
]
instance HasPath ApiV2RegistriesController where
pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types"
pathTo ApiV2ListEventTypesAction = "/api/v2/event-types"
pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories"
pathTo ApiV2ListPolicyScopesAction = "/api/v2/policy-scopes"
instance CanRoute ApiV2OpenApiController where
parseRoute' = do
@@ -242,6 +245,61 @@ instance HasPath ApiV2HubRegistryController where
pathTo ApiV2IndexHubRegistryAction = "/api/v2/hub-registry"
pathTo ApiV2ShowHubRegistryAction { hubId } = "/api/v2/hub-registry/" <> tshow hubId
instance CanRoute ApiV2HubsController where
parseRoute' = do
_ <- string "/api/v2/hubs"
choice
[ do endOfInput; pure ApiV2IndexHubsAction
, do _ <- string "/"; hId <- parseUUID; endOfInput
pure ApiV2ShowHubAction { hubId = Id hId }
]
instance HasPath ApiV2HubsController where
pathTo ApiV2IndexHubsAction = "/api/v2/hubs"
pathTo ApiV2ShowHubAction { hubId } = "/api/v2/hubs/" <> tshow hubId
pathTo ApiV2CreateHubAction = "/api/v2/hubs"
instance CanRoute ApiV2HubCapabilityManifestsController where
parseRoute' = do
_ <- string "/api/v2/hub-capability-manifests"
choice
[ do endOfInput; pure ApiV2IndexHubCapabilityManifestsAction
, do _ <- string "/"; mId <- parseUUID
choice
[ do _ <- string "/activate"; endOfInput
pure ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId = Id mId }
, do endOfInput
pure ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId = Id mId }
]
]
instance HasPath ApiV2HubCapabilityManifestsController where
pathTo ApiV2IndexHubCapabilityManifestsAction = "/api/v2/hub-capability-manifests"
pathTo ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
pathTo ApiV2CreateHubCapabilityManifestAction = "/api/v2/hub-capability-manifests"
pathTo ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
pathTo ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId <> "/activate"
instance CanRoute ApiV2ApiConsumersController where
parseRoute' = do
_ <- string "/api/v2/api-consumers"
choice
[ do endOfInput; pure ApiV2IndexApiConsumersAction
, do _ <- string "/"; cId <- parseUUID
choice
[ do _ <- string "/api-keys"; endOfInput
pure ApiV2CreateApiConsumerKeyAction { apiConsumerId = Id cId }
, do endOfInput
pure ApiV2ShowApiConsumerAction { apiConsumerId = Id cId }
]
]
instance HasPath ApiV2ApiConsumersController where
pathTo ApiV2IndexApiConsumersAction = "/api/v2/api-consumers"
pathTo ApiV2ShowApiConsumerAction { apiConsumerId } = "/api/v2/api-consumers/" <> tshow apiConsumerId
pathTo ApiV2CreateApiConsumerAction = "/api/v2/api-consumers"
pathTo ApiV2CreateApiConsumerKeyAction { apiConsumerId } = "/api/v2/api-consumers/" <> tshow apiConsumerId <> "/api-keys"
instance CanRoute ApiV2WidgetPatternsController where
parseRoute' = do
_ <- string "/api/v2/widget-patterns"

View File

@@ -285,6 +285,7 @@ data ApiDashboardController
data ApiV2WidgetsController
= ApiV2IndexWidgetsAction
| ApiV2ShowWidgetAction { widgetId :: !(Id Widget) }
| ApiV2CreateWidgetAction
deriving (Eq, Show, Data)
data ApiV2InteractionEventsController
@@ -323,6 +324,7 @@ data ApiV2RegistriesController
= ApiV2ListWidgetTypesAction
| ApiV2ListEventTypesAction
| ApiV2ListAnnotationCategoriesAction
| ApiV2ListPolicyScopesAction
deriving (Eq, Show, Data)
data ApiV2OpenApiController
@@ -400,6 +402,27 @@ data ApiV2HubRegistryController
| ApiV2ShowHubRegistryAction { hubId :: !(Id Hub) }
deriving (Eq, Show, Data)
data ApiV2HubsController
= ApiV2IndexHubsAction
| ApiV2ShowHubAction { hubId :: !(Id Hub) }
| ApiV2CreateHubAction
deriving (Eq, Show, Data)
data ApiV2HubCapabilityManifestsController
= ApiV2IndexHubCapabilityManifestsAction
| ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
| ApiV2CreateHubCapabilityManifestAction
| ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
| ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
deriving (Eq, Show, Data)
data ApiV2ApiConsumersController
= ApiV2IndexApiConsumersAction
| ApiV2ShowApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) }
| ApiV2CreateApiConsumerAction
| ApiV2CreateApiConsumerKeyAction { apiConsumerId :: !(Id ApiConsumer) }
deriving (Eq, Show, Data)
data ApiV2WidgetPatternsController
= ApiV2IndexWidgetPatternsAction
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }

View File

@@ -53,6 +53,7 @@ renderRow row@HubRegistryRow { hub, mManifest, mLatestSnapshot } =
{hub.name}
</a>
<span class="text-xs text-gray-400 font-mono">{hub.hubKind}</span>
{classificationBadge hub}
{gaafBadge gs}
</div>
<div class="flex items-center gap-4 text-xs text-gray-500">
@@ -74,6 +75,17 @@ gaafBadge GaafDraftOnly =
gaafBadge GaafNoManifest =
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">no manifest</span>|]
classificationBadge :: Hub -> Html
classificationBadge hub =
case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of
(Just "vsm", Just functionName, Just systemName) ->
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">VSM {functionName} / {vsmSystemLabel systemName}</span>|]
_ -> mempty
vsmSystemLabel :: Text -> Text
vsmSystemLabel "environment" = "Environment"
vsmSystemLabel systemName = "System " <> systemName
healthScoreBadge :: Int -> Html
healthScoreBadge s =
let cls :: Text

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">Domain</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Kind</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">Family</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
@@ -41,6 +42,17 @@ kindBadge "framework" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-purple-
kindBadge "shared" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-teal-100 text-teal-800">shared</span>|]
kindBadge _ = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800">domain</span>|]
classificationBadge :: Hub -> Html
classificationBadge hub =
case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of
(Just "vsm", Just functionName, Just systemName) ->
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">VSM {functionName} / {vsmSystemLabel systemName}</span>|]
_ -> [hsx|<span class="text-xs text-gray-400">-</span>|]
vsmSystemLabel :: Text -> Text
vsmSystemLabel "environment" = "Environment"
vsmSystemLabel systemName = "System " <> systemName
renderHub :: Hub -> Html
renderHub hub = [hsx|
<tr class="border-b border-gray-100 hover:bg-gray-50">
@@ -53,6 +65,7 @@ renderHub hub = [hsx|
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{hub.slug}</td>
<td class="px-4 py-3 text-gray-500">{hub.domain}</td>
<td class="px-4 py-3">{kindBadge hub.hubKind}</td>
<td class="px-4 py-3">{classificationBadge hub}</td>
<td class="px-4 py-3 text-right">
<a href={EditHubAction (hub.id)}
class="text-gray-500 hover:text-gray-700 text-xs mr-3">Edit</a>

View File

@@ -27,6 +27,7 @@ instance View ShowView where
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold">{hub.name}</h1>
{kindBadge hub.hubKind}
{classificationBadge hub}
</div>
<p class="text-sm text-gray-500 mt-1">
<span class="font-mono bg-gray-100 px-1 rounded">{hub.slug}</span>
@@ -223,6 +224,17 @@ kindBadge "framework" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-purple-
kindBadge "shared" = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-teal-100 text-teal-800">shared</span>|]
kindBadge _ = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800">domain</span>|]
classificationBadge :: Hub -> Html
classificationBadge hub =
case (hub.hubFamily, hub.vsmFunction, hub.vsmSystem) of
(Just "vsm", Just functionName, Just systemName) ->
[hsx|<span class="px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">VSM {functionName} / {vsmSystemLabel systemName}</span>|]
_ -> mempty
vsmSystemLabel :: Text -> Text
vsmSystemLabel "environment" = "Environment"
vsmSystemLabel systemName = "System " <> systemName
maybeText :: Maybe Text -> [Text]
maybeText Nothing = []
maybeText (Just t) = [t]

View File

@@ -115,7 +115,7 @@ typeForm entry hubs isNew = [hsx|
{renderNameField isNew entry.name}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>

View File

@@ -115,7 +115,7 @@ typeForm entry hubs isNew = [hsx|
{renderNameField isNew entry.name}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>

View File

@@ -115,7 +115,7 @@ typeForm entry hubs isNew = [hsx|
{renderNameField isNew entry.name}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>

View File

@@ -116,7 +116,7 @@ typeForm entry hubs isNew = [hsx|
{renderNameField isNew entry.name}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
{(textField #label_) { fieldClass = "w-full border border-gray-300 rounded px-3 py-2 text-sm" }}
<input type="text" name="label_" value={entry.label_} required="required" class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-gray-400 text-xs">(optional)</span></label>

13
app.toml Normal file
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)
The v2 API supersedes per-hub Bearer tokens with OAuth 2.0 client credentials.
The v2 API supports authenticated Bearer access with static API keys and, where
configured, OAuth 2.0 client credentials.
**OpenAPI spec:** `/api/v2/openapi.json` (live-generated; `widget_type`,
`event_type`, and `category` fields carry `enum` arrays from the type registries)
`GET /api/v2/hubs` and the vocabulary registry list endpoints are public
discovery surfaces. Mutating bootstrap operations still require Bearer access.
**New endpoints in v2:**
- `POST /api/v2/token` — OAuth 2.0 client credentials token exchange
- `GET /api/v2/hubs` / `POST /api/v2/hubs` — list or create hubs, including
first-class VSM hub metadata
- `GET /api/v2/hub-capability-manifests` / `POST /api/v2/hub-capability-manifests`
— list or create hub capability manifests
- `PATCH /api/v2/hub-capability-manifests/{id}` — update draft manifest
vocabulary and metadata
- `POST /api/v2/hub-capability-manifests/{id}/activate` — activate a draft
manifest and register its declared vocabulary
- `GET /api/v2/api-consumers` / `POST /api/v2/api-consumers` — list or create
API consumers
- `POST /api/v2/api-consumers/{id}/api-keys` — create a static API key; the
raw `fullKey` is returned exactly once
- `GET /api/v2/widgets` — paginated widget listing
- `POST /api/v2/widgets` — create a widget
- `GET /api/v2/interaction-events` — paginated event listing
- `POST /api/v2/interaction-events` — submit event (registry-validated)
- `GET /api/v2/annotations` — paginated annotation listing

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,95 @@
# Ops Hub Activity-Core Fallback Validation
Date: 2026-06-15
Workplan: `IHUB-WP-0022`
## Validation Result
The State Hub fallback path is implemented and tested in activity-core, but no
live fallback event exists in State Hub yet.
Direct query:
```text
GET http://127.0.0.1:8000/progress/?event_type=ops_inventory_probe&limit=20
```
Observed result on 2026-06-15:
```json
[]
```
This means Inter-Hub can accept the fallback-first design, but cannot yet cite
live `ops_inventory_probe` evidence as a closure artifact.
## What Is Validated
Activity-core local tests validate the fallback sink shape:
- `state-hub-progress` posts one compact `ops_inventory_probe` progress event
per run.
- The fallback idempotency key is stable:
`activity_core_run_id:context_key:ops_inventory_probe`.
- The posted summary is compact, for example:
`Ops inventory probe: 1 ok, 0 degraded, 0 down, 1 skipped`.
- The detail includes `activity_id`, `activity_core_run_id`, `scheduled_for`,
`source_type`, `context_key`, `idempotency_key`, and a compact `probe`
payload.
- The compact probe strips response bodies, authorization headers, credentials,
URL query strings, and token-like values.
- Inter-Hub sink config absence is a clean skip with
`reason = missing_inter_hub_config`.
- When config is present but submission is not implemented, the result is a
clean skip with `reason = inter_hub_sink_deferred`.
## What Is Not Yet Validated
- No live activity-core worker has posted an `ops_inventory_probe` event to the
local State Hub instance.
- No live Inter-Hub event has been submitted from activity-core.
- No production `OPS_HUB_KEY` handoff has been verified.
- No `OPS_HUB_WIDGET_MAPPING` has been deployed into the activity-core runtime.
- No per-entity widget mapping has been smoke-tested against production
ops-hub widgets.
## Gaps Compared With Inter-Hub Submission
State Hub fallback is useful as continuity evidence, but it is not a full
replacement for Inter-Hub submission:
- It records one compact run summary, not one governed widget event per entity.
- It cannot attach annotations directly to the affected service or endpoint
widget.
- It does not prove ops-hub manifest vocabulary enforcement.
- It does not prove API consumer, key, rate-limit, or widget mapping behavior.
- It does not populate Inter-Hub event lists for hub dashboards or downstream
widget governance workflows.
## Closure Recommendation
`ACTIVITY-WP-0007/T06` should not close as fully activated based on the current
State Hub state, because no live `ops_inventory_probe` progress event exists.
Two acceptable closure paths remain:
1. Fallback-deferred closure: run one disabled/manual activity-core probe,
confirm a live non-secret `ops_inventory_probe` progress event in State Hub,
and explicitly record that Inter-Hub submission is deferred until
`IHUB-WP-0022-T03/T04/T07` complete.
2. Full Inter-Hub closure: provision `OPS_HUB_KEY`, deploy
`OPS_HUB_WIDGET_MAPPING`, seed/verify the ops-hub widgets, and submit one
accepted Inter-Hub event per activity-core event type.
Until one of those paths is satisfied, Inter-Hub should keep the activity-core
closure gate open.
## Next Evidence To Capture
- Progress id for the first live `ops_inventory_probe` event.
- Non-secret summary counts from that progress detail.
- Confirmation that Inter-Hub sink remains skipped cleanly while config is
absent or deferred.
- After ops-hub activation, event ids for one accepted Inter-Hub submission per
event type.

347
docs/new-hub-quickstart.md Normal file
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,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

@@ -18,7 +18,7 @@
systems = import systems;
imports = [ ihp.flakeModules.default ];
perSystem = { pkgs, ... }: {
perSystem = { pkgs, config, lib, ... }: {
ihp = {
appName = "inter-hub";
enable = true;
@@ -77,6 +77,12 @@
# static.makeBundling = true; # Set false if not using Makefile for CSS/JS bundling
};
# OCI container image for Kubernetes deployment (Railiance01).
# Build: nix build .#docker
# Push: skopeo copy docker-archive:result docker://92.205.130.254:32166/coulomb/inter-hub:SHA
# Uses IHP's built-in unoptimized image; binary is /bin/RunProdServer.
packages.docker = config.packages.unoptimized-docker-image;
# Custom configuration that will start with `devenv up`
devenv.shells.default = {
# Start Mailhog on local development to catch outgoing emails
@@ -85,6 +91,89 @@
# PostgreSQL extensions
# services.postgres.extensions = extensions: [ extensions.postgis ];
# GHC 9.10.3 crash fix: Generated.ActualTypes uses `module M` re-export
# syntax for 61 sub-modules; the resulting ActualTypes.hi exceeds GHC's
# ~274 MB binary-deserialization limit (crash at position 287686318).
#
# pkgs is built from `import nixpkgs { overlays = devenv.shells.default.overlays; }`.
# IHP adds ihp.overlays.default to this list, which sets
# pkgs.ghc = haskellPackages.override { overrides = ihpOverrides }.
# We extend pkgs.ghc with a mkDerivation override (lib.mkAfter ensures
# we run after IHP's overlay, so prev.ghc is already IHP's package set).
# For inter-hub-models: rewrite ActualTypes.hs export list from (module N)
# syntax to explicit T(..) re-exports — keeps hub functional for qualified
# references (Generated.ActualTypes.T) while producing compact .hi.
# Generated.Types stubbed; inter-hub-lib replaces its import with direct
# entity imports (sourceRoot is per-package, originals intact there).
overlays = lib.mkAfter [
(final: prev: {
ghc = prev.ghc.extend (hfinal: hprev: {
mkDerivation = args:
let drv = hprev.mkDerivation args;
in if (args.pname or "") == "inter-hub-models"
then drv.overrideAttrs (old: {
# GHC 9.10.3 crash: `module M` re-export syntax in Generated.ActualTypes
# embeds 61 full sub-interfaces into ActualTypes.hi (~287 MB), exceeding
# GHC's 274 MB binary read limit.
#
# Fix: rewrite the hub's export list from `module M` syntax to explicit
# T(..) re-exports. Explicit re-exports store only name references in
# the .hi file (compact); `module M` embeds the full sub-interface.
# Hub stays functional (consumers still qualify via Generated.ActualTypes),
# but .hi stays small.
configureFlags = (old.configureFlags or []) ++ [
"--ghc-option=-O0"
"--ghc-option=-fomit-interface-pragmas"
"--disable-split-sections"
"--ghc-option=-j1"
# GHC 9.10.3 bug: libHSghc-9.10.3-5702.a is truncated (last AR
# entry Expr.o claims 517544 bytes but only 82258 remain).
# GHC's internal static linker (readAr via Data.Binary.Get) panics
# after all 477 modules compile when it flushes deferred symbol
# loads from IHP's TH splices that transitively need the ghc pkg.
# Fix: delegate TH evaluation to ghc-iserv-dyn, which uses dlopen
# on libHSghc.so (intact) instead of readAr on the truncated .a.
# ghc-iserv-dyn is not in ghc-with-packages/bin/, so use -pgmi
# with the absolute path in the unwrapped GHC store path.
"--ghc-option=-fexternal-interpreter"
"--ghc-option=-pgmi"
"--ghc-option=${hprev.ghc}/lib/ghc-9.10.3/bin/ghc-iserv-dyn"
];
postUnpack = (old.postUnpack or "") + ''
_actual="$sourceRoot/build/Generated/ActualTypes.hs"
# Rewrite hub export list: (module N, ...) explicit names.
# IHP pattern: data Foo' params = Foo {...} (primed type, unprimed ctor)
# type Foo = Foo' arg1 arg2 (concrete alias, kind *)
# ADTs: export T(..) to include type + ctor + fields.
# type aliases: export T (no (..) not an ADT).
# type instance lines start with lowercase 'i', so don't match [A-Z].
_types=$(
{
awk '/^data [A-Z]|^newtype [A-Z]/{print $2"(..)"}
/^type [A-Z]/{print $2}' \
"$sourceRoot/build/Generated/Enums.hs"
find "$sourceRoot/build/Generated/ActualTypes" -name "*.hs" | \
sort | while IFS= read -r _m; do
awk '/^data [A-Z]|^newtype [A-Z]/{print $2"(..)"}
/^type [A-Z]/{print $2}' "$_m"
done
} | sort -u
)
_exports=$(echo "$_types" | \
awk 'NR==1{printf " %s", $0; next} {printf "\n , %s", $0} END{printf "\n"}')
_imports=$(awk '/^import Generated\./{print}' "$_actual")
{
printf 'module Generated.ActualTypes\n ( %s ) where\n' "$_exports"
printf '%s\n' "$_imports"
} > "$_actual.new" && mv "$_actual.new" "$_actual"
'';
})
else drv;
});
})
];
# Resource limits for constrained host (2 CPU, ~3.8 GiB RAM).
# -A32m: smaller minor heap (reduces GC pressure).
# -M2g: hard heap ceiling (prevents OOM on large compiles).

12
registry/README.md Normal file
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,72 @@
---
id: ADHOC-2026-06-15
type: workplan
title: "Ad hoc Inter-Hub production fixes"
domain: custodian
repo: inter-hub
status: active
owner: codex
created: "2026-06-15"
updated: "2026-06-15"
state_hub_workstream_id: "9e7a50b4-da7f-4df9-9154-7b89a071f520"
---
# Ad hoc Inter-Hub production fixes
## Fix COUNT decode failures in v2 bootstrap endpoints
```task
id: ADHOC-2026-06-15-T01
status: wait
priority: high
state_hub_task_id: "cceee9f1-56af-44bc-898d-21c4508df07c"
```
Production Ops Hub bootstrap exposed a PostgreSQL/Haskell type mismatch in
the v2 API helpers. `COUNT(*)` returns `bigint`, while the helper code decoded
the result as `Int`, causing `UnexpectedColumnTypeStatementError` in widget
type validation and API request log rate-limit checks.
Fix the count queries so widget creation and authenticated hub-registry reads
work through the documented v2 bootstrap API.
Source fix on 2026-06-15:
- `Application/Helper/TypeRegistry.hs` now casts registry validation
`COUNT(*)` queries to `int`.
- `Application/Helper/ApiRateLimit.hs` now casts API request log
`COUNT(*)` queries to `int`.
- Commit `5101eb5 Fix API count decoding` was pushed to `origin/main`.
Blocked before live completion:
- The Gitea deploy workflow did not update production during the session.
- Production still reports image `gitea.coulomb.social/coulomb/inter-hub:5c13de1`.
- Local `nix develop ... scripts/compile-check` is blocked by local devenv
setup, and the local `nix build .#docker` remained in dependency compilation
after more than 20 minutes. The build was stopped cleanly.
Deploy trigger attempt on 2026-06-15:
- Confirmed current `main` contains the COUNT decode fix and is at commit
`f8fde35`.
- Confirmed the deploy workflow is the normal path and is pinned to
`runs-on: [self-hosted, haskelseed]`.
- Confirmed image tag `gitea.coulomb.social/coulomb/inter-hub:f8fde35`
returns `manifest unknown`.
- Gitea Actions API inspection/dispatch was attempted using the locally
configured `tea` token, but the public HTTPS API returned `401 Unauthorized`
for Actions endpoints; the raw configured HTTP endpoint was not reachable
from this session.
- Pushed empty commit `68c66b9` (`chore: trigger inter-hub deploy`) because
the previous contract/docs commit was ignored by the deploy workflow's
`paths-ignore` rules.
- Polled the registry for
`gitea.coulomb.social/coulomb/inter-hub:68c66b9` for about five minutes
after push; it continued to return `manifest unknown`.
Current wait reason: the source fix is pushed, but image publication/deploy now
requires authenticated Gitea Actions workflow dispatch or inspection of the
self-hosted `haskelseed` runner path. The normal workflow needs haskelseed as
build runner; an equivalent operator-controlled build host with Nix, registry
push credentials, and Railiance deploy credentials could substitute.

View File

@@ -4,11 +4,12 @@ type: workplan
title: "IHF Phase 9 — External API Surface and Consumer SDKs"
domain: inter_hub
repo: inter-hub
status: active
status: done
owner: custodian
topic_slug: inter_hub
created: "2026-04-01"
updated: "2026-04-01"
updated: "2026-06-07"
completed: "2026-06-07"
state_hub_sync: done
state_hub_workstream_id: "c6c6e87f-e145-4bc4-9881-61f92b14d4de"
---
@@ -68,6 +69,12 @@ Schema additions:
- `webhook_deliveries` table
- `api_request_log` table (for usage dashboard and rate limiting)
## Close-out Correction - 2026-06-07
State Hub showed IHUB-WP-0010 as active even though all eleven task rows were
already `done`. The workplan frontmatter and final exit checklist were corrected
to reflect the completed Phase 9 state.
---
## Tasks
@@ -748,17 +755,17 @@ Enforce per-consumer limits and close out the workplan.
**Exit criteria (Phase 9 complete when all of these are true):**
- [ ] All core IHF artifact types are readable via `/api/v2/`
- [ ] Interaction events and annotations are writable via `/api/v2/`
- [ ] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
- [x] All core IHF artifact types are readable via `/api/v2/`
- [x] Interaction events and annotations are writable via `/api/v2/`
- [x] OpenAPI spec generated; `widget_type`, `event_type`, `category` carry
`enum` arrays from live registries
- [ ] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
- [ ] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
- [ ] Webhook delivery confirmed for `interaction_event.created` and
- [x] TypeScript SDK at `/api/v2/sdk/ihf-client.ts` exports correct enums
- [x] Python SDK at `/api/v2/sdk/ihf-client.py` exports correct enums
- [x] Webhook delivery confirmed for `interaction_event.created` and
`requirement_candidate.created`
- [ ] API usage dashboard renders correctly with AutoRefresh
- [ ] OAuth client credentials flow works end-to-end
- [ ] Submission of an unregistered `event_type` returns HTTP 422 with
- [x] API usage dashboard renders correctly with AutoRefresh
- [x] OAuth client credentials flow works end-to-end
- [x] Submission of an unregistered `event_type` returns HTTP 422 with
registry-referenced error
- [ ] Rate limiting returns 429 with `Retry-After`
- [ ] CLAUDE.md updated; IHUB-WP-0010 listed as complete
- [x] Rate limiting returns 429 with `Retry-After`
- [x] CLAUDE.md updated; IHUB-WP-0010 listed as complete

View File

@@ -0,0 +1,596 @@
---
id: IHUB-WP-0018
type: workplan
title: "Railiance01 Deployment — Production Operations Scaffold"
domain: inter_hub
repo: inter-hub
status: finished
owner: custodian
topic_slug: inter_hub
created: "2026-04-29"
updated: "2026-06-14"
depends_on: IHUB-WP-0015
state_hub_workstream_id: "080d841a-3acd-4adf-b684-2d1890a5e986"
---
# IHUB-WP-0018 — Railiance01 Deployment: Production Operations Scaffold
## Goal
Deploy inter-hub to the Railiance01 Kubernetes cluster with fully automatic
deployment, SOPS-encrypted secrets, Traefik ingress, PostgreSQL HA, and a
Gitea Actions CI/CD pipeline. After this workplan, every push to `main`
automatically builds an OCI container image on haskelseed, pushes it to the
Railiance container registry, and deploys it — with automatic restart on node
reboot guaranteed by K3s.
## Background
inter-hub v0.2.0-alpha.1 is running on haskelseed (Alpine) via RunDevServer
and socat. That setup is a development convenience, not a production operations
scaffold. The target is the Railiance01 K3s cluster, which has:
- K3s (single-node for now; ThreePhoenix HA cluster is in progress)
- Traefik ingress with TLS
- PostgreSQL HA (repmgr + pgpool) managed by railiance-platform
- SOPS/age secret management
- Gitea with built-in container registry (or separate registry service)
- Staged Promotion Lifecycle CLI (`railiance run / deploy / promote / rollback`)
**Key constraint:** This workplan depends on Railiance01 K3s being operational.
Gate R3 verifies cluster readiness before any deployment work begins — if K3s
or the container registry is not ready, this workplan blocks there and the
cluster work must be completed first.
**IHP specifics:** IHP DevServer is a development server. For production we
build the IHP binary via `nix build` (which produces a self-contained binary)
and wrap it in a minimal OCI image using Nix's `dockerTools.buildImage`. The
app serves HTTP on port 8000; the socat workaround is not needed in Kubernetes
since Traefik routes directly to the pod's port.
## Architecture
```
git push → Gitea Actions
→ SSH to haskelseed: nix build → docker load → docker push registry/inter-hub:$SHA
→ helm upgrade inter-hub railiance-apps/helm/inter-hub
→ Deployment (1 replica): inter-hub:$SHA + env from Secrets
→ Service (ClusterIP :8000)
→ Ingress (Traefik): hub.coulomb.social → Service
→ PersistentVolumeClaim: /app/static (generated CSS/JS)
→ PostgreSQL: database 'interhub' on railiance-platform HA cluster
```
## Close-out Audit - 2026-06-04
WSJF triage flagged this workplan as a close-out candidate because State Hub had
no indexed task rows for it. The deployment work is not complete; this file now
contains explicit task blocks so the hub can track the remaining Railiance01
deployment work instead of treating the workplan as empty.
## Deployment Review - 2026-06-05
Review against the current repo and public Railiance endpoint shows the
deployment scaffold is partially implemented but the live deployment is behind
`origin/main`.
- `origin/main` is at `a3d980c`, which includes the completed ops-hub bootstrap
API work from `IHUB-WP-0019`.
- `https://hub.coulomb.social/` returns 200 and serves inter-hub.
- The public OpenAPI only lists the older v2 endpoints; it does not include
`/hubs`, `/hub-capability-manifests`, `/api-consumers`, or `/policy-scopes`.
- Unauthenticated `/api/v2/hubs` returns 404 publicly, while current source
should route it and return 401. This means ops-hub bootstrap cannot run
against production until the current image is deployed.
- The registry endpoint returns the expected unauthenticated `/v2/` 401
challenge, but this workspace does not have `kubectl`, so R3 cluster readiness
cannot be fully verified from here.
## Tasks
### R1 - Add OCI image build to flake.nix
```task
id: IHUB-WP-0018-T01
status: done
priority: high
state_hub_task_id: "27420bd7-0f70-4793-8805-393d8d5cacfd"
```
Add a `packages.docker` output to `flake.nix` using `pkgs.dockerTools.buildLayeredImage`.
The image wraps the IHP production binary produced by `nix build .#default`.
```nix
packages.docker = pkgs.dockerTools.buildLayeredImage {
name = "inter-hub";
tag = "latest";
contents = [ self.packages.${system}.default pkgs.cacert ];
config = {
Cmd = [ "/bin/inter-hub" ];
ExposedPorts = { "8000/tcp" = {}; };
Env = [
"PORT=8000"
"IHP_ENV=Production"
];
};
};
```
Test locally on haskelseed:
```bash
nix build .#docker
docker load < result
docker run --rm -p 8000:8000 -e DATABASE_URL=... -e IHP_SESSION_SECRET=... inter-hub:latest
```
**Note:** First build pulls the full Haskell binary closure (~2 GB); subsequent
builds are incremental (layer caching). Build must run on haskelseed - the only
machine with the Nix store populated for GHC 9.10.3.
**Implementation note (2026-06-05):** `flake.nix` exposes `packages.docker =
config.packages.unoptimized-docker-image`, the IHP-provided production OCI
image used by the Railiance runbook. The original `buildLayeredImage` sketch is
superseded by that IHP image path.
### R2 — Verify container runs correctly
```task
id: IHUB-WP-0018-T02
status: done
priority: high
state_hub_task_id: "5ab45e4e-16bc-4feb-8b1b-e8eeb05bf39a"
```
On haskelseed, run the container image against the existing `interhub` database.
Confirm:
- `curl http://localhost:8000/` returns 200 (LandingAction)
- `curl http://localhost:8000/api/v2/hubs` returns 200 (public discovery)
- Static assets load (Tailwind CSS present in image)
- Container exits cleanly on SIGTERM
If Tailwind CSS output (`static/app.css`) is not bundled into the Nix binary
closure, add a pre-build step: run tailwindcss and include `static/` in the
image via `dockerTools.buildLayeredImage` `contents` or a NixOS module.
### R3 — Verify Railiance01 readiness (gate)
```task
id: IHUB-WP-0018-T03
status: done
priority: high
state_hub_task_id: "79b5cf2c-3a5b-4b4b-8f84-f635cb6891c1"
```
This is a dependency gate. Before proceeding, confirm:
```bash
# From CoulombCore (execution origin):
kubectl get nodes # must show Ready
kubectl get pods -n kube-system | grep traefik # Traefik must be running
kubectl get pods -n railiance-platform # PostgreSQL HA pods
```
Also confirm:
- Container registry is reachable from haskelseed (verify push access)
- Registry address (e.g., `registry.coulomb.social` or `gitea.coulomb.social`)
- SOPS/age key is present on CoulombCore at `~/.config/sops/age/keys.txt`
If any check fails, block here and open the relevant Railiance workstream.
Do not proceed until all checks pass.
**Review note (2026-06-05):** Public smoke probes show
`https://hub.coulomb.social/` returning 200 and the Gitea registry `/v2/`
endpoint returning the expected unauthenticated 401 challenge. Full R3 remains
blocked from this workspace because `kubectl` is not available here, and the
live app is not serving the current `origin/main` v2 bootstrap routes.
**Recovery note (2026-06-14):** Re-established the haskelseed ops-bridge path
and verified the runner substrate before deployment. `make runner-status` in
`railiance-forge` confirmed `act_runner` is registered to
`https://gitea.coulomb.social`, running under OpenRC, and has the expected
self-hosted labels and build/deploy tools. The K3s API path, Helm deploy path,
and Gitea registry host were exercised successfully by the production rollout.
### R4 — Provision inter-hub database on railiance-platform
```task
id: IHUB-WP-0018-T04
status: done
priority: high
state_hub_task_id: "c937cf36-3850-4ab3-aa83-2d846e1a378e"
```
On the PostgreSQL HA cluster, create the inter-hub database and user:
```sql
CREATE USER interhub WITH PASSWORD '<generated>';
CREATE DATABASE interhub OWNER interhub;
GRANT ALL PRIVILEGES ON DATABASE interhub TO interhub;
```
Run schema migration (IHP migrations) as part of the first deployment via an
init container or a manual `migrate` run inside the pod. Document the
migration procedure in `deploy/railiance/RUNBOOK.md`.
**Recovery note (2026-06-14):** Bootstrapped the production database manually on
the Railiance PostgreSQL cluster: role `interhub`, database `interhub`, schema
ownership, and privileges were created/updated. The running deployment now uses
that database through the `inter-hub-env` Kubernetes Secret.
**Production initialization note (2026-06-14):** After DNS/TLS and network
access were restored, production OpenAPI still failed because the `interhub`
database was blank (`public_table_count:0`). The IHP production image only
contains `RunProdServer` and `RunJobs`, so there was no packaged migration
runner to execute. Initialized the database through the CloudNativePG pod by
loading `Application/Schema.sql` in one transaction, applying the idempotent
type-registry seed migration `1744502400`, and granting app privileges on the
new schema to the `interhub` role. The default admin seed with a known password
was intentionally not applied to production.
### R5 — SOPS-encrypted secrets
```task
id: IHUB-WP-0018-T05
status: done
priority: high
state_hub_task_id: "926f82d1-15cd-425d-8a41-3d6b51c07f0b"
```
Create `deploy/railiance/secrets/inter-hub.env.sops.yaml` with:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: inter-hub-env
namespace: inter-hub
type: Opaque
stringData:
DATABASE_URL: postgresql://interhub:<pass>@net-kingdom-pg-rw.databases.svc.cluster.local:5432/interhub?sslmode=disable
IHP_SESSION_SECRET: <64-char-hex>
IHP_BASEURL: https://hub.coulomb.social
PORT: "8000"
IHP_ENV: Production
```
Encrypt with the age key:
```bash
sops --encrypt \
--age age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 \
/tmp/inter-hub-env.yaml > deploy/railiance/secrets/inter-hub.env.sops.yaml
```
Commit only the encrypted file. Apply it with
`sops -d deploy/railiance/secrets/inter-hub.env.sops.yaml | kubectl apply -f -`.
**Recovery note (2026-06-14):** Runtime secrets were bootstrapped manually in
Kubernetes so production could deploy safely. This task remains in progress
until the durable SOPS-encrypted source for `DATABASE_URL`, `IHP_SESSION_SECRET`,
and related runtime env is committed and wired into the deploy path.
**Progress note (2026-06-14):** Added repo root `.sops.yaml`, plaintext
guardrails under `deploy/railiance/secrets/`, an example Secret manifest, and
`k8s-secret-json-to-sops-input.py` to convert the live Kubernetes Secret into a
SOPS-ready manifest without printing values. At that point the encrypted source
file was still pending because local `sops` tooling was not available.
**Completion note (2026-06-14):** Created
`deploy/railiance/secrets/inter-hub.env.sops.yaml` from the live
`inter-hub/inter-hub-env` Kubernetes Secret using temporary `sops` v3.13.1 and
the shared Railiance age recipient. Verified the file is SOPS-encrypted, parses
as YAML, leaves only non-secret metadata reviewable, and does not contain the
checked plaintext runtime markers. Decryption/apply verification remains a
custody-backed operator capability because the private age identity is not
present in the normal workstation or haskelseed shell.
### R6 — Helm chart in railiance-apps
```task
id: IHUB-WP-0018-T06
status: done
priority: high
state_hub_task_id: "4c4acc98-5773-4289-ad57-03f3fd5c381c"
```
Create `charts/inter-hub/` in the `railiance-apps` repository following the
Railiance app.toml contract. Minimal chart:
```
charts/inter-hub/
Chart.yaml name: inter-hub, version: 0.1.0
values.yaml image.tag, ingress.host, resources
helm/inter-hub-values.yaml
production non-secret overrides
templates/
deployment.yaml envFrom: secretRef inter-hub-env
service.yaml ClusterIP :8000
ingress.yaml Traefik annotations, TLS
```
`app.toml` in the inter-hub repo root for railiance CLI integration:
```toml
[app]
name = "inter-hub"
slug = "inter-hub"
kind = "native"
registry = "gitea.coulomb.social/coulomb/inter-hub"
[deploy]
chart = "railiance-apps/charts/inter-hub"
namespace = "inter-hub"
```
**Implementation note (2026-06-05):** A Helm chart exists in
`deploy/helm/inter-hub/` with Deployment, Service, Ingress, and values for the
current Gitea registry and `hub.coulomb.social`. Remaining gaps: no repo-root
`app.toml`, no committed SOPS secret manifest, and no separate
`railiance-apps/helm/inter-hub` handoff in this repo.
**Recovery note (2026-06-14):** The local chart under `deploy/helm/inter-hub/`
successfully deployed the app to Railiance01. This task remains in progress
because the repo-root `app.toml` and railiance-apps handoff are still not
completed.
**Completion note (2026-06-14):** Added repo-root `app.toml` in inter-hub and
added `charts/inter-hub`, `helm/inter-hub-values.yaml`, Makefile targets, and
server-dry-run coverage in `railiance-apps`. The chart rendered successfully on
haskelseed with `helm template`.
### R7 — Gitea Actions CI/CD pipeline
```task
id: IHUB-WP-0018-T07
status: done
priority: medium
state_hub_task_id: "ec25c67c-3cb0-4534-9fb0-9bd6578a2def"
```
Create `.gitea/workflows/deploy.yaml` in the inter-hub repo:
```yaml
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest # or self-hosted if available
steps:
- uses: actions/checkout@v4
- name: Build OCI image on haskelseed
run: |
ssh haskelseed "cd /root/inter-hub && git pull && \
nix build .#docker && \
docker load < result && \
docker tag inter-hub:latest $REGISTRY/inter-hub:${{ github.sha }} && \
docker push $REGISTRY/inter-hub:${{ github.sha }}"
- name: Deploy to Railiance01
run: |
ssh coulombcore "helm upgrade --install inter-hub \
railiance-apps/helm/inter-hub \
--namespace inter-hub --create-namespace \
--set image.tag=${{ github.sha }} \
-f railiance-apps/helm/inter-hub/values.prod.yaml"
```
Secrets in Gitea: `REGISTRY`, `SSH_KEY_HASKELSEED`, `SSH_KEY_COULOMBCORE`.
**Alternative if self-hosted runner is available on CoulombCore:** run the
deploy step directly without the SSH hop to coulombcore.
**Implementation note (2026-06-05):** `.gitea/workflows/deploy.yaml` exists and
builds `.#docker` on a self-hosted `haskelseed` runner, pushes to
`92.205.130.254:32166/coulomb/inter-hub`, deploys with Helm, and smoke-tests
the public endpoint. Remote `main` is already current, but production is still
serving an older API surface, so the workflow needs an attended rerun/inspection
or a new deployment trigger.
**Runner substrate finding (2026-06-07):** Pushed commits `fa96fb8` and
`7cc3173` to trigger the workflow, but public `/api/v2/hubs` remained `404`
while `/` stayed `200`, indicating the current image was not deployed. Repo
search shows `railiance-forge` owns Actions runner substrate, but its
2026-06-05 migration plan explicitly lists "No Actions runner deployment" as a
non-goal and no runner manifest/script/workplan exists there yet. `haskelseed`
itself is reachable on SSH and historical port 8080, but this workspace cannot
authenticate non-interactively. Treat R7 as blocked on a forge-owned runner
prerequisite rather than continuing to push commits as deployment probes.
**Recovery note (2026-06-14):** The runner prerequisite was restored through
the haskelseed ops-bridge path. The workflow now builds the Nix OCI image,
publishes to `gitea.coulomb.social/coulomb/inter-hub` using a registry bearer
token from the repo `REGISTRY_TOKEN` Actions secret, deploys with Helm, and
runs public smoke checks. Gitea Actions run `2913` completed successfully for
commit `5663fab`.
**Load-control note (2026-06-14):** Added workflow `paths-ignore` for docs,
workplans, `.custodian-brief.md`, `app.toml`, `.sops.yaml`, and
`deploy/railiance/**` so State Hub consistency/doc-only commits do not consume a
haskelseed build/deploy cycle.
**Bootstrap-gate deploy note (2026-06-14):** Hardened the deployment workflow
smoke test so a production rollout only passes when `/api/v2/hubs` returns the
expected unauthenticated `401` and OpenAPI exposes `/hubs`,
`/hub-capability-manifests`, `/api-consumers`, and `/policy-scopes`. This
directly protects the ops-hub bootstrap gate instead of only checking the
landing page and generic widget auth gate.
**Authenticated inspection note (2026-06-14):** The stored local Tea token is
stale for `https://gitea.coulomb.social`, but runner-side inspection succeeded.
`make runner-status` in `railiance-forge` showed `act_runner` registered to
`https://gitea.coulomb.social`, started under OpenRC, and carrying the expected
`self-hosted`/`haskelseed` labels. The runner log shows task `19` for
`coulomb/inter-hub` starting at `2026-06-14T19:59:19+02:00`, matching the
`6455902` deploy trigger.
### R8 — Staged deployment and smoke test
```task
id: IHUB-WP-0018-T08
status: done
priority: high
state_hub_task_id: "2b02ae5c-47b9-4f09-88f0-a4af7900b38f"
```
Follow the Railiance staged promotion lifecycle:
1. **Local verify** (done in R2 — container runs correctly)
2. **Deploy to Railiance01:**
```bash
railiance deploy inter-hub --tag <sha>
```
3. **Smoke test:**
```bash
curl -s https://hub.coulomb.social/ | grep "Inter-Hub" # Landing page
curl -s https://hub.coulomb.social/capabilities # Capabilities
curl -H "Authorization: Bearer <key>" \
https://hub.coulomb.social/api/v2/hubs # API (200)
curl https://hub.coulomb.social/api/v2/hubs # Unauthenticated (200)
```
4. **Verify restart persistence:**
```bash
kubectl rollout restart deployment/inter-hub -n inter-hub
kubectl rollout status deployment/inter-hub -n inter-hub
# Then re-run smoke test
```
**Recovery note (2026-06-14):** Production is deployed from image
`gitea.coulomb.social/coulomb/inter-hub:5663fab`; Kubernetes reports the
`inter-hub` deployment ready with one replica. Public smoke checks pass:
`/` returns 200 and contains `inter-hub`, `/api/v2/openapi.json` returns 200,
and unauthenticated `/api/v2/widgets` returns 401.
**DNS gate finding (2026-06-14):** The deployment workflow did publish and
deploy `gitea.coulomb.social/coulomb/inter-hub:6455902`; Kubernetes reports the
`inter-hub` Deployment ready on the COULOMBCORE K3s node
`92.205.130.254`. An in-cluster probe to
`http://inter-hub:8000/api/v2/hubs` returned the expected unauthenticated
`401`, and forcing public TLS to `92.205.130.254` also returned `401`. The
public DNS record for `hub.coulomb.social`, however, resolves to
`92.205.62.239`, where `/api/v2/hubs` still returns `404` and OpenAPI lacks the
bootstrap paths. The remaining production gate is therefore DNS cutover (or an
intentional kubeconfig rotation to the cluster behind `92.205.62.239`), not a
runner, build, registry, Helm, or image-content issue.
**Production gate completion note (2026-06-14):** DNS for
`hub.coulomb.social` now resolves to `92.205.130.254`, cert-manager issued a
Let's Encrypt certificate for the host, and the app deployment is serving image
`gitea.coulomb.social/coulomb/inter-hub:6455902`. The final blockers were
database ingress from `inter-hub` to `net-kingdom-pg` and the blank production
schema. Added/applied the platform NetworkPolicy, initialized the `interhub`
schema and framework type registries, granted privileges to the app role, and
restarted the deployment. The ops-hub route probe now passes:
`/api/v2/hubs` returns an unauthenticated response,
`/api/v2/openapi.json` returns `200`, and OpenAPI exposes `/hubs`,
`/hub-capability-manifests`, `/api-consumers`, and `/policy-scopes`.
### R9 — Document and register
```task
id: IHUB-WP-0018-T09
status: done
priority: medium
state_hub_task_id: "4d1e55c7-8dbb-480f-b07b-6c5e39a04218"
```
- Write `deploy/railiance/RUNBOOK.md`: image build, migration procedure,
secret rotation, rollback (`railiance rollback inter-hub`), log access
(`kubectl logs -n inter-hub -l app=inter-hub --tail=100`)
- Add progress event to state hub
- Remove haskelseed socat/OpenRC production role note from quickstart -
document it as the build machine only, not the production host
**Implementation note (2026-06-05):** `deploy/railiance/RUNBOOK.md` exists and
documents architecture, image build/push, Helm deployment, logs, restart,
rollback, secret rotation, and smoke checks. The deployment record remains
incomplete until current `main` is running and the ops-hub bootstrap smoke test
passes against production.
**Recovery note (2026-06-14):** Current `main` is running in production and the
deployment evidence has been recorded here. Remaining documentation work is to
capture the durable secret-management and railiance-apps handoff path once R5
and R6 are completed.
**Completion note (2026-06-14):** Updated `deploy/railiance/RUNBOOK.md` for the
current Gitea registry host, runner-based build/deploy path, SOPS secret handoff,
current smoke checks, and haskelseed's build-runner-only role. Updated
`docs/new-hub-quickstart.md` so haskelseed is no longer described as a
production/shared database runtime.
### R10 - Externally verify ops-hub bootstrap gate follow-up
```task
id: IHUB-WP-0018-T10
status: done
priority: high
```
Added after the helix-forge follow-up asking Inter-Hub to re-check the
production bootstrap API gate from an external client before ops-hub proceeds.
**Verification note (2026-06-14):** External public probes from this workstation
confirmed the deployed route existed, but this check treated the wrong status as
success:
- `getent ahosts hub.coulomb.social` resolves to `92.205.130.254`.
- `curl -s -o /tmp/interhub-hubs-body.txt -w "%{http_code}" \
https://hub.coulomb.social/api/v2/hubs` returned `401`, which confirmed the
route existed but not the correct public-discovery contract.
- The unauthenticated response body was an API auth failure:
`{"code":"invalid_api_key","error":"Unauthorized"}`.
- `curl -s -o /tmp/interhub-openapi.json -w "%{http_code}" \
https://hub.coulomb.social/api/v2/openapi.json` returned `200`.
- Parsing `paths` from the downloaded OpenAPI document found all required
bootstrap paths: `/hubs`, `/hub-capability-manifests`, `/api-consumers`, and
`/policy-scopes`.
The deployed workflow smoke test also now captures `/api/v2/hubs` status
without `curl -f`, verifies it equals `401`, and fails deployment if any of the
four bootstrap OpenAPI paths are missing.
### R11 - Correct public hub discovery bootstrap contract
```task
id: IHUB-WP-0018-T11
status: done
priority: high
```
Follow-up correction after reviewing the ops-hub bootstrap hurdle: `GET
/api/v2/hubs` is a discovery endpoint and should return `200` without an API
key, not `401`. The authenticated boundary belongs on mutating bootstrap
operations such as `POST /api/v2/hubs`, manifest writes/activation, API
consumer creation, API key creation, and runtime widget/event submission.
**Implementation note (2026-06-14):** Updated the Hubs v2 controller so
unauthenticated `GET /api/v2/hubs` returns the paginated hub list, while
`POST /api/v2/hubs` still requires an API consumer. Updated generated OpenAPI
contract helpers so public discovery operations explicitly set `security: []`
instead of inheriting top-level Bearer auth. Updated the deployment workflow to
require `/api/v2/hubs` to return `200` with a paginated `data` response, and
updated the ops-hub bootstrap smoke helper to use unauthenticated hub discovery
before authenticated mutations.
## Exit Criteria
- `https://hub.coulomb.social/` returns the Landing page (200, no auth)
- `/api/v2/hubs` returns 200 unauthenticated for discovery
- All 12 IHF dashboards accessible after admin login
- `kubectl rollout restart` followed by smoke test passes (K3s restart
persistence confirmed)
- Gitea Actions pipeline: push to `main` → image built → deployed → smoke
test green within 15 minutes
- No dependency on haskelseed being up for the app to *run* (only for builds)
## Open Questions / Pre-flight Checks
1. **K3s status**: ThreePhoenix HA cluster workstream is active but not complete.
Confirm whether Railiance01 is a single-node cluster already accepting
workloads or still being provisioned. Gate R3 is the go/no-go check.
2. **Container registry**: Is Gitea's built-in registry available on Railiance01,
or is a separate registry service needed? If neither, add registry deployment
to the scope.
3. **PostgreSQL HA status**: railiance-platform baseline workstream is active.
Confirm whether the HA cluster (repmgr + pgpool) is operational before R4.
4. **Static asset bundling**: The Nix production binary may or may not include
`static/app.css` (Tailwind output). Verify in R2 and adjust image build
if needed.
5. **Anthropic API key**: Phase 5 AI-assisted distillation requires
`IHP_ANTHROPIC_API_KEY`. Add to SOPS secrets if the feature is to be
active on Railiance01.

View File

@@ -0,0 +1,323 @@
---
id: IHUB-WP-0019
type: workplan
title: "VSM Hub Bootstrap API Hardening"
domain: inter_hub
repo: inter-hub
status: finished
owner: codex
topic_slug: inter_hub
created: "2026-05-16"
updated: "2026-05-19"
planning_priority: high
planning_order: 19
related_repos:
- helix-forge
related_workplans:
- HF-WP-0001
state_hub_workstream_id: "ebde2b8b-8863-4008-9ebf-9bb0300d7375"
---
# VSM Hub Bootstrap API Hardening
## Goal
Make Inter-Hub capable of bootstrapping VSM domain hubs, starting with
`ops-hub`, through documented API calls or an explicit admin bootstrap command
instead of manual UI-only setup or ad hoc database migrations.
This workplan is linked from `helix-forge` workplan `HF-WP-0001`, where
`ops-hub` is being established as the first VSM Inter-Hub extension.
## Background
The current Inter-Hub implementation already supports the essential concepts:
- `Hub`
- `HubCapabilityManifest`
- type registries for widget types, event types, annotation categories, and
policy scopes
- `ApiConsumer`
- `ApiKey`
- widgets
- v2 interaction events
However, the live public v2 API is currently read-heavy. The initial
`ops-hub` bootstrap still requires authenticated UI flows or deployment-side
migrations for hub creation, manifest creation/activation, API consumer/key
creation, and widget seeding.
For HelixForge's VSM hub family, the desired repeatable bootstrap shape is:
```text
Hub identity + VSM function + manifest vocabulary + API consumer + seed widgets + evidence events
```
The same shape should later work for:
- `ops-hub` — Operations / System 1
- `syn-hub` — Synchronization / System 2
- `ctl-hub` — Internal Control / System 3
- `aud-hub` — Audit / System 3*
- `int-hub` — Intelligence and Adaptation / System 4
- `pol-hub` — Policy and Identity / System 5
- `env-hub` — Environment boundary
## Constraints
- Preserve the GAAF rule that hub-owned type names are declared through
`HubCapabilityManifest` before use.
- Do not weaken append-only invariants on `interaction_events`.
- Do not expose raw static API keys after creation.
- Keep UI flows working while adding API/admin bootstrap support.
- Prefer explicit request schemas in OpenAPI instead of reusing response
schemas as create contracts.
## Tasks
### T01 — Add scriptable hub and widget creation endpoints
```task
id: IHUB-WP-0019-T01
status: done
priority: high
state_hub_task_id: "72c5b7b2-632f-42ab-ac4d-eff123d8f143"
```
Add documented v2 create endpoints for the records needed during a hub
bootstrap:
- `POST /api/v2/hubs`
- `POST /api/v2/widgets`
The endpoints should validate the same invariants as the UI controllers:
- hub slug/name/domain are required
- `hubKind` accepts supported values only
- widget type is registered
- policy scope is registered when the registry is enforced
- widget belongs to an existing hub
Done when: a script can create a hub row and seed widgets without direct DB
access.
Implementation note (2026-05-16): added authenticated v2 `POST /api/v2/hubs`
and `POST /api/v2/widgets`, with required-field validation, hub-kind/status
validation, widget type and policy-scope registry checks, hub existence checks,
initial widget-version snapshots, OpenAPI path entries, SDK helper methods, and
focused Hspec helper coverage. The collection controllers now dispatch
GET/POST by HTTP method so the create routes are reachable. Local
`git diff --check` passed; `scripts/compile-check` could not run because this
shell does not have `IHP_LIB`/the IHP dev environment loaded.
---
### T02 — Add manifest and policy-scope API support
```task
id: IHUB-WP-0019-T02
status: done
priority: high
state_hub_task_id: "46a027d0-4831-40af-b8ae-e1f858cdaef7"
```
Add documented API or admin-command support for:
- `HubCapabilityManifest` draft creation
- manifest vocabulary update
- manifest activation
- listing policy scopes at `/api/v2/policy-scopes`
Done when: manifest activation can be executed without clicking through the UI
and all four type registries are visible through v2 list endpoints.
Implementation note (2026-05-16): added authenticated
`/api/v2/hub-capability-manifests` support for draft create, draft update, and
activation, including the same manifest vocabulary conflict checks and
idempotent registry upserts used by the UI flow. Added
`/api/v2/policy-scopes`, OpenAPI path/schema entries, SDK helper methods, and
focused Hspec helper coverage for manifest vocabulary parsing. Local
`git diff --check` passed; `scripts/compile-check` could not run because this
shell does not have `IHP_LIB`/the IHP dev environment loaded.
---
### T03 — Add first-class VSM hub metadata
```task
id: IHUB-WP-0019-T03
status: done
priority: medium
state_hub_task_id: "a90a0220-3d02-4b97-9fbf-a6bbbfa5019c"
```
Decide where VSM hub-family metadata belongs and implement it consistently.
Candidate fields:
- `hub_family`
- `vsm_function`
- `vsm_system`
Candidate placement:
- new columns on `hubs`
- manifest metadata JSON
- a separate hub classification table
Done when: `ops-hub` can be represented as the VSM Operations / System 1 hub
without hiding that classification inside prose.
Implementation note (2026-05-19): chose new nullable columns on `hubs`
(`hub_family`, `vsm_function`, `vsm_system`) because the VSM role is hub
identity/classification metadata, not manifest vocabulary. Added migration
`1744588800-vsm-hub-metadata.sql`, schema constraints, v2 hub create/list/show
JSON, hub registry JSON, compact registry/UI badges, OpenAPI request/response
fields, SDK parameters, and validation tests. API validation now accepts either
no VSM fields or `hubFamily=vsm` with non-empty `vsmFunction` and a supported
`vsmSystem` (`1`, `2`, `3`, `3*`, `4`, `5`, or `environment`). `git diff
--check` passed; `scripts/compile-check` is still blocked in this shell because
`IHP_LIB` is not set.
---
### T04 — Add API consumer and API key bootstrap support
```task
id: IHUB-WP-0019-T04
status: done
priority: high
state_hub_task_id: "a50114d7-8719-45d5-9081-948df147d500"
```
Add either documented v2 endpoints or an admin-only bootstrap command for:
- creating an `ApiConsumer`
- binding it to an active manifest
- creating a static API key
- returning the full key exactly once
Done when: an operator can create an ops-hub API credential from a repeatable
command while preserving the one-time secret display invariant.
Implementation note (2026-05-19): added authenticated v2
`/api/v2/api-consumers` support for consumer create/list/show, including active
manifest binding validation, positive rate-limit/quota validation, and
`POST /api/v2/api-consumers/:id/api-keys` for one-time static key generation.
Key hashes are stored; the raw `fullKey` is returned only in the key creation
response. Added OpenAPI/SDK entries and focused Hspec helper coverage. Local
`git diff --check` passed; `scripts/compile-check` could not run because this
shell does not have `IHP_LIB`/the IHP dev environment loaded.
---
### T05 — Fix interaction-event create contract gaps
```task
id: IHUB-WP-0019-T05
status: done
priority: high
state_hub_task_id: "1febfdb6-757b-420a-b4bd-709ce3cd1252"
```
Fix the current v2 interaction event create behavior:
- decode active manifest `declaredEventTypes` instead of treating it as empty
- persist submitted `metadata` if metadata is part of the create contract
- dispatch webhooks using the submitted event type instead of hard-coded
`"clicked"`
- add tests around manifest-declared domain events
Done when: `ops-endpoint-verified` can be submitted with metadata and routed
as an ops-owned event.
Implementation note (2026-05-16): v2 interaction-event creation now validates
against active manifest-declared event types, persists submitted metadata from
JSON request bodies, dispatches webhooks with the submitted event type, and has
focused Hspec coverage for manifest-declared ops domain events. Local
`git diff --check` passed; `scripts/compile-check` could not run because this
shell does not have `IHP_LIB`/the IHP dev environment loaded.
---
### T06 — Update OpenAPI request schemas and hub quickstart docs
```task
id: IHUB-WP-0019-T06
status: done
priority: medium
state_hub_task_id: "84c92e05-3e0f-490a-a48f-e2d9ddace764"
```
Update OpenAPI and docs to match the real API:
- add distinct create request schemas for hubs, widgets, annotations,
interaction events, manifests, consumers, and keys where applicable
- remove or clearly mark aspirational quickstart calls until the endpoints
exist
- document the VSM hub bootstrap recipe using `ops-hub` as the example
Done when: a new hub implementer can follow docs without discovering missing
API endpoints at runtime.
Implementation note (2026-05-19): updated the generated OpenAPI contract to
use distinct request schemas for hub, manifest, API consumer/key, widget,
interaction-event, and annotation writes. The spec now represents manifest
activation and widget-pattern adoption as no-body actions, documents the
one-time `ApiKeyCreatedResponse.fullKey`, adds missing hub registry and widget
pattern response schemas, and fixes path parameter naming for `hubId`. Updated
`docs/new-hub-quickstart.md` to show the supported `ops-hub` bootstrap path
through `/api/v2`, including VSM metadata, manifest activation, consumer/key
creation, widget seeding, and `metadata` event submission. Updated the
functional contract endpoint list. `git diff --check` passed;
`scripts/compile-check` remains blocked in this shell because `IHP_LIB` is not
set.
---
### T07 — Add an ops-hub bootstrap smoke test
```task
id: IHUB-WP-0019-T07
status: done
priority: medium
state_hub_task_id: "409b5f85-ec97-42e4-ad21-09e91b49639c"
```
Add a smoke test or scripted check that exercises the full bootstrap path:
1. create `ops-hub`
2. create and activate the manifest
3. create API consumer/key
4. seed a widget
5. submit an `ops-endpoint-verified` event with metadata
6. verify the event is listed by v2
Done when: the next VSM hub can be bootstrapped by adapting the same script
and changing only vocabulary/configuration values.
Implementation note (2026-05-19): added executable
`scripts/ops-hub-bootstrap-smoke.py`. The script uses only Python standard
library modules and drives the documented v2 path end to end: creates or
reuses `ops-hub`, creates/activates the ops manifest, creates a fresh runtime
API consumer and one-time key, creates or reuses an ops widget, submits
`ops-endpoint-verified` with metadata, and verifies the event through the v2
list endpoint. It requires `IHUB_OPERATOR_KEY` and accepts `IHUB_BASE` plus hub
identity overrides for adapting the same recipe to another VSM hub. Updated the
quickstart to point to the script. `python3 -m py_compile` and `git diff
--check` passed; the live smoke run itself requires a running Inter-Hub service
and an operator API key.
## Acceptance Criteria
This workplan is complete when:
1. `ops-hub` can be created without direct DB access.
2. Its manifest can be created and activated without manual UI-only steps.
3. Its API consumer/key can be created by a repeatable operator path.
4. Its widgets can be seeded by API or a documented admin command.
5. Its first operational event can persist metadata and dispatch webhooks using
the actual submitted event type.
6. OpenAPI and docs accurately describe the supported bootstrap path.
7. The same path is reusable for `syn-hub`, `ctl-hub`, `aud-hub`, `int-hub`,
`pol-hub`, and `env-hub`.

View File

@@ -0,0 +1,299 @@
---
id: IHUB-WP-0020
type: workplan
title: "Personal Dashboard Framework"
domain: inter_hub
repo: inter-hub
status: backlog
owner: tegwick
topic_slug: inter_hub
created: "2026-05-03"
updated: "2026-06-07"
phase: 13
state_hub_workstream_id: "72fc022b-0196-492a-aaba-3475f8768f06"
---
# Personal Dashboard Framework
## Goal
Design and implement a personal dashboard framework that allows individual users to
compose, configure, and persist a view of the inter-hub platform tailored to their
role and focus. The dashboard is the post-login landing page and the primary daily
driver surface for hub operators, governance reviewers, and AI orchestrators.
## Motivation
The current post-login experience drops the user on a raw Hubs list. As inter-hub
grows to 12+ phases of functionality, users need a curated, role-aware entry point
that surfaces the signals that matter to them without requiring manual navigation.
The dashboard is also the natural home for cross-cutting observability (recent
decisions, open candidates, outcome signals) that cuts across the current
controller-per-entity navigation.
---
## Tasks
### T01 — Research: Dashboard frameworks and patterns for inspiration
```task
id: IHUB-WP-0020-T01
status: todo
priority: high
state_hub_task_id: "6074f195-636b-4517-b6d1-eb3c57394a82"
```
Survey existing dashboard systems to extract patterns that are re-implementable in
Haskell / IHP / Tailwind under inter-hub's design constraints (server-rendered,
type-safe, governed).
**Research targets:**
| System | What to extract |
|---|---|
| **Grafana** | Panel/grid layout model; datasource abstraction; variable-driven filtering |
| **Kibana (dashboards)** | Saved-search panels; time-range awareness; role-based visibility |
| **Retool / Appsmith** | Widget catalogue approach; drag-grid layout; data binding model |
| **Linear (home view)** | Flat "My Work" aggregation across entities; priority surfacing |
| **Notion (linked databases)** | Filter/sort persistence per user; view types (table, board, calendar) |
| **Observable Framework** | Reactive cell model; markdown + code co-location |
| **Metabase** | Question-as-unit; dashboard as ordered collection of questions |
| **Streamlit** | Declarative layout (columns, expanders); pure functional rendering loop |
**Questions to answer per system:**
1. How is a dashboard persisted? (JSON blob, relational rows, code-as-config?)
2. How is a widget/panel parameterised? (datasource, filter, display options)
3. How is layout described? (fixed grid, CSS grid, drag-and-drop, responsive breakpoints)
4. How is per-user state separated from shared/team state?
5. What is the update model? (full-page reload, WebSocket push, polling, partial HTMX swap)
6. How are access controls expressed at panel level?
**IHP/Haskell-specific constraints to keep in mind:**
- Server-rendered by default; AutoRefresh (WebSocket) available for live data
- No client-side state management library; JS must be minimal
- Type safety from DB schema → view layer is a first-class constraint
- Tailwind CSS; no component library
**Exit criteria:** Research notes written in `docs/research/dashboard-frameworks.md`.
---
### T02 — Product Requirements Specification (PRS)
```task
id: IHUB-WP-0020-T02
status: todo
priority: high
depends_on: T01
state_hub_task_id: "698304bc-b91a-42e2-a617-b3ddbf749174"
```
Produce a formal PRS for the Personal Dashboard Framework based on T01 findings and
inter-hub's design principles.
**Required sections:**
1. **Problem statement** — who uses the dashboard, what decisions they make with it,
what pain the current flat-nav approach causes
2. **User personas**
- Hub Operator: monitors activity within their hub; wants recent events, open candidates
- Governance Reviewer: triages candidates, reviews decisions; needs queue and signal views
- AI Orchestrator: monitors agent proposals, outcome correlations; needs performance panels
- Platform Admin: watches system health, API usage, learning throughput
3. **Core requirements (MoSCoW)**
- Must: per-user dashboard persisted in DB; selectable panels from a catalogue;
layout preserved across sessions; role-aware default layout on first login
- Should: panel-level filtering (by hub, by time range); live-update via AutoRefresh
for signal panels; keyboard-navigable
- Could: drag-and-drop layout editing; shared/team dashboards; dashboard templates
- Won't (Phase 13): mobile-native layout; client-side data fetching; external datasources
4. **Non-functional requirements**
- First paint < 500 ms (server-rendered, no JS data fetching)
- Dashboard save/load < 100 ms
- Each panel query < 200 ms (indexed, bounded result sets)
- Zero JS frameworks; AutoRefresh WebSocket for live panels only
5. **Governance fit** — dashboard widgets are themselves `Widget` records in the IHF
sense; `InteractionEvent`s recorded on dashboard interactions; annotations possible
on any panel
**Exit criteria:** `docs/prs/dashboard-framework-prs.md` reviewed and accepted.
---
### T03 — Functional Design Document (FDD)
```task
id: IHUB-WP-0020-T03
status: todo
priority: high
depends_on: T02
state_hub_task_id: "438e5771-a043-4f26-a1ce-994ed478a760"
```
Translate the PRS into a concrete functional design covering schema, component model,
rendering pipeline, and layout system. This is the authoritative reference for implementation.
**Required sections:**
#### 3.1 Data model
```sql
-- A user's named dashboard
CREATE TABLE dashboards (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- An instance of a panel type on a dashboard, with position
CREATE TABLE dashboard_panels (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
panel_type TEXT NOT NULL, -- FK to panel_type_registry
config JSONB NOT NULL DEFAULT '{}',
col INT NOT NULL DEFAULT 0,
row INT NOT NULL DEFAULT 0,
col_span INT NOT NULL DEFAULT 1,
row_span INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Registry of available panel types
CREATE TABLE panel_type_registry (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
description TEXT,
default_config JSONB NOT NULL DEFAULT '{}',
requires_hub BOOLEAN NOT NULL DEFAULT FALSE,
live_update BOOLEAN NOT NULL DEFAULT FALSE
);
```
#### 3.2 Panel catalogue (Phase 13 scope)
| Panel type name | Label | Description | Live? |
|---|---|---|---|
| `recent-interactions` | Recent Activity | Latest interaction events across watched hubs | Yes |
| `open-candidates` | Open Candidates | Requirement candidates awaiting triage | No |
| `decision-queue` | Decision Queue | Decisions pending review | No |
| `outcome-signals` | Outcome Signals | Recent outcome signal summary | Yes |
| `hub-health` | Hub Health | Health snapshot per hub | No |
| `agent-proposals` | Agent Proposals | Open AI agent proposals | No |
| `learning-digest` | Learning Digest | Latest institutional knowledge entries | No |
| `my-annotations` | My Annotations | Annotations by the current user | No |
#### 3.3 Layout system
12-column CSS grid; panels occupy `col_span` columns and `row_span` rows.
Row height fixed at 240px. No drag-and-drop in Phase 13; layout edited via
form fields (col, row, span). Responsive: collapse to single column below 768px.
```
┌─────────────────────────────────────────────────────┐
│ [Recent Activity ×6] [Decision Queue ×3] [Hub ×3]│
│ │
├─────────────────────────────────────────────────────┤
│ [Open Candidates ×4] [Outcome Signals ×4] [My ×4] │
└─────────────────────────────────────────────────────┘
```
#### 3.4 Rendering pipeline
```
GET /Dashboard
→ DashboardController#show
→ fetch dashboard + panels (ordered by row, col)
→ for each panel: dispatch to panel renderer (panelHtml panelType config)
→ embed in DashboardView with CSS grid layout
→ AutoRefresh wraps live panels only
```
Each panel renderer is a Haskell function:
```haskell
type PanelRenderer = PanelConfig -> ModelContext -> IO Html
renderPanel :: Text -> PanelConfig -> ModelContext -> IO Html
renderPanel "recent-interactions" = renderRecentInteractions
renderPanel "open-candidates" = renderOpenCandidates
...
```
#### 3.5 Edit flow
`GET /Dashboard/Edit` → grid with inline forms per panel (col/row/span inputs) +
"Add Panel" dropdown from `panel_type_registry`. `POST /Dashboard` saves layout.
No JavaScript needed for basic edit; optional HTMX for panel preview.
#### 3.6 Default dashboard on first login
`afterLoginRedirectPath` (in `SessionsControllerConfig`) redirects to `/Dashboard`.
On first visit, `DashboardController` checks for an existing dashboard; if absent,
creates a default one seeded from the user's role (determined by a `role` field
to be added to `users`, or a simple heuristic based on existing data).
**Exit criteria:** FDD reviewed; schema migrations drafted; panel catalogue agreed.
---
### T04 — Implementation workplan
```task
id: IHUB-WP-0020-T04
status: todo
priority: medium
depends_on: T03
state_hub_task_id: "970aa221-7e17-4500-8b37-9c98676280b1"
```
Break T03's FDD into a detailed, sequenced task list suitable for execution as a new
workplan (IHUB-WP-0021). Each task must have a clear entry/exit criterion and fit within
the 8k token soft budget.
**Expected task structure of IHUB-WP-0021:**
```
T01 Schema migration: dashboards, dashboard_panels, panel_type_registry
T02 Seed: default panel types in panel_type_registry
T03 DashboardController — show action (fetch + render)
T04 Panel renderers — first 3 panels (recent-interactions, open-candidates, decision-queue)
T05 DashboardView — CSS grid layout
T06 Panel renderers — remaining 5 panels
T07 Dashboard edit flow (layout form, add/remove panels)
T08 Default dashboard seeding on first login
T09 afterLoginRedirectPath → /Dashboard
T10 AutoRefresh for live panels (recent-interactions, outcome-signals)
T11 Role-aware default layout
T12 Smoke tests
```
**Exit criteria:** IHUB-WP-0021 workplan file committed; T01T12 each have
entry/exit criteria; ready for execution.
---
## Exit Criteria Summary
| Task | Deliverable | Status |
|---|---|---|
| T01 | `docs/research/dashboard-frameworks.md` | todo |
| T02 | `docs/prs/dashboard-framework-prs.md` | todo |
| T03 | `docs/fdd/dashboard-framework-fdd.md` | todo |
| T04 | `workplans/IHUB-WP-0021-personal-dashboard-impl.md` | todo |
## Design Principles (binding throughout)
- **Server-first**: every panel renders in a single round-trip. No client-side data fetching.
- **Type-safe config**: `PanelConfig` is a Haskell ADT, not an opaque JSON blob at runtime.
- **IHF governed**: each rendered panel is a `Widget` with a `widgetEnvelope`; interactions
are recorded; annotations can be attached.
- **Tailwind only**: no external component library. Layout via CSS Grid with inline style for
structural properties (lessons learned from sidebar nav).
- **Minimal JS**: AutoRefresh WebSocket for live panels; vanilla JS for any UX enhancement.
No framework, no bundler beyond the existing IHP asset pipeline.

View File

@@ -0,0 +1,420 @@
---
id: IHUB-WP-0022
type: workplan
title: "Ops Hub Evidence Intake for Activity Core"
domain: inter_hub
repo: inter-hub
status: active
owner: codex
topic_slug: inter_hub
created: "2026-06-15"
updated: "2026-06-15"
planning_priority: high
planning_order: 22
related_repos:
- activity-core
- helix-forge
related_workplans:
- ACTIVITY-WP-0007
- IHUB-WP-0019
- HF-WP-0001
state_hub_workstream_id: "bd086c41-287d-4a4e-8ac5-9ab270f14d72"
---
# Ops Hub Evidence Intake for Activity Core
## Goal
Prepare the Inter-Hub `ops-hub` intake side for activity-core operational
evidence events so `ACTIVITY-WP-0007` can move from State Hub fallback
summaries to governed Inter-Hub submissions without ad hoc database access or
ungoverned secrets.
This workplan comes from the activity-core suggestion:
- Message `18b4bf54-6fae-422b-ab29-8586bfc094e8`, created 2026-06-05:
prepare the ops-hub intake side for `ops-service-observed`,
`ops-endpoint-verified`, `ops-access-path-checked`,
`ops-backup-verified`, and `ops-inventory-drift`.
- Related closure-gate message `f3ec4a36-6abf-4550-be92-39f5709863de`,
created 2026-06-07: activity-core can fall back to State Hub
`ops_inventory_probe` summaries, but final activation waits on the Inter-Hub
path or an explicit deferral decision.
Numbering note: `IHUB-WP-0021` is intentionally left available for the
personal-dashboard implementation workplan already named by
`IHUB-WP-0020-T04`.
## Background
Inter-Hub already has the generic bootstrap surface from `IHUB-WP-0019`:
- `POST /api/v2/hubs`
- `POST /api/v2/hub-capability-manifests`
- manifest activation with declared widget, event, annotation, and policy
vocabulary
- `POST /api/v2/api-consumers`
- one-time API key creation
- `POST /api/v2/widgets`
- `POST /api/v2/interaction-events`
The quickstart in `docs/new-hub-quickstart.md` shows this path for `ops-hub`.
However, the activity-core evidence stream needs a concrete, durable contract:
which ops-hub widgets receive which event types, how `OPS_HUB_WIDGET_MAPPING`
is shaped, where the runtime key is provisioned, and which payload fields
activity-core may rely on.
Current production caveat as of 2026-06-15: the source fix for COUNT decoding
is committed as `5101eb5`, but production image publication/deployment is
still tracked in `ADHOC-2026-06-15`. Do not treat widget-create or
hub-registry smoke checks as production-ready until that deployment gate is
closed.
## Scope
### In Scope
- Define the `ops-hub` evidence vocabulary and target widget mapping for
activity-core.
- Document `OPS_HUB_WIDGET_MAPPING` and the expected event payload shape.
- Provision or hand off the `OPS_HUB_KEY` secret through an approved
operator-owned secret store outside Git.
- Validate the State Hub fallback-first path through `ops_inventory_probe`.
- Enable per-entity Inter-Hub submissions only after the widget/API-key path
is live and smoke-tested.
- Produce closure guidance for `ACTIVITY-WP-0007/T06`.
### Out of Scope
- Building activity-core's evidence sink implementation.
- Storing static API keys in Git, State Hub, workplans, logs, or chat.
- Manual production DB seeding except under explicit operator approval.
- Expanding ops-hub beyond the five activity-core evidence event types.
- Changing the public/private authentication contract for existing v2 reads.
## Proposed Evidence Vocabulary
Activity-core has already declared the event contracts it wants to send:
| Event type | Suggested widget family | Purpose |
|---|---|---|
| `ops-service-observed` | service inventory | Record that a service exists and was observed. |
| `ops-endpoint-verified` | endpoint inventory | Record endpoint reachability, auth challenge, or health verification. |
| `ops-access-path-checked` | access path inventory | Record operator or service access path verification. |
| `ops-backup-verified` | backup inventory | Record backup presence, recency, or restore-drill evidence. |
| `ops-inventory-drift` | drift inventory | Record drift between expected and observed operations inventory. |
The first implementation should keep one stable widget per entity and evidence
family where possible. If activity-core cannot know entity identity reliably,
use one aggregate intake widget per family as a conservative first slice, then
split into per-entity widgets after payload evidence proves stable.
## Tasks
### T01 - Audit current ops-hub bootstrap and activity-core contracts
```task
id: IHUB-WP-0022-T01
status: done
priority: high
state_hub_task_id: "f9006504-e5f5-465f-9588-3f4279d12b84"
```
Review the current Inter-Hub API, `docs/new-hub-quickstart.md`, the latest
activity-core evidence sink contract, and State Hub messages related to
`ACTIVITY-WP-0007`.
Answer:
- Which event types are already declared by activity-core?
- Which widget types and policy scopes should ops-hub declare?
- Does the live Inter-Hub deployment include the `5101eb5` COUNT decode fix?
- Is `ops-hub` already present in the target environment, and is its manifest
active?
- Is there an existing API consumer/key that should be reused, rotated, or
replaced?
Exit criteria: `docs/research/ops-hub-evidence-intake-current-state.md`
exists with non-secret findings and open gates.
Implementation note (2026-06-15): completed
`docs/research/ops-hub-evidence-intake-current-state.md`. The audit confirms
that Inter-Hub has the required v2 widget and interaction-event primitives,
activity-core has the five event definitions and a tested State Hub fallback
sink, but the Inter-Hub sink is still deferred and no live
`ops_inventory_probe` progress event exists in State Hub yet.
---
### T02 - Define the ops-hub evidence mapping contract
```task
id: IHUB-WP-0022-T02
status: done
priority: high
depends_on: T01
state_hub_task_id: "4f8a98b9-0d01-4333-b847-f83b8c85a5ab"
```
Define the durable mapping that activity-core should receive as
`OPS_HUB_WIDGET_MAPPING`.
The contract must specify:
- Map shape and version field.
- Event type keys.
- Widget identifiers or stable logical names for each event family.
- Entity selector shape for per-service, per-endpoint, per-access-path, and
per-backup submissions.
- Fallback aggregate widgets for uncertain entity mapping.
- Policy scope for operational evidence writes.
- Rotation and compatibility expectations when widgets are renamed or split.
Exit criteria: `docs/contracts/ops-hub-activity-core-mapping.md` documents the
mapping in copyable JSON examples without containing secrets.
Implementation note (2026-06-15): completed
`docs/contracts/ops-hub-activity-core-mapping.md`. The contract defines a
versioned non-secret `OPS_HUB_WIDGET_MAPPING` JSON shape, aggregate-first
fallback widgets, per-entity selector rules, stable `widgetRef` values, and
Secret-only handling for `OPS_HUB_KEY`.
---
### T03 - Prepare manifest vocabulary and seed widgets
```task
id: IHUB-WP-0022-T03
status: wait
priority: high
depends_on: T02
state_hub_task_id: "94fc9806-781c-45f6-a43c-a6bce13da47b"
```
Use the supported v2 bootstrap surface, or a documented operator-approved
bootstrap script, to ensure `ops-hub` declares and activates the required
vocabulary.
Required vocabulary:
- Widget type or types for service, endpoint, access path, backup, and drift
evidence.
- Event types listed in the activity-core suggestion.
- Annotation category for operational risk or follow-up review.
- Policy scope for ops evidence writes.
Seed the initial widgets named by the mapping contract.
Exit criteria:
- The active ops-hub manifest declares all required event types.
- The widgets named in `OPS_HUB_WIDGET_MAPPING` exist.
- `POST /api/v2/widgets` no longer fails with COUNT decode errors in the
target environment.
- Non-secret widget IDs or logical names are recorded in the mapping contract.
Current wait reason (2026-06-15): manifest/widget activation needs a target
environment with the `5101eb5` COUNT decode fix live and an authenticated
operator/runtime key path. The required vocabulary is documented, but no live
manifest or widget seed was performed in this implementation slice.
---
### T04 - Provision the runtime API key outside Git
```task
id: IHUB-WP-0022-T04
status: wait
priority: high
depends_on: T03
state_hub_task_id: "267db6a7-67d2-48af-b3e8-7588f8684957"
```
Create or confirm the ops-hub runtime API consumer and static key, then store
the full key only in the approved operator-owned secret store.
Rules:
- Never print, commit, or paste the full static key into Git, State Hub, or
chat.
- If a key already exists in a temporary local file, move it into the approved
secret path and remove the temp file after verification.
- Prefer OpenBao path `platform/operators/ops-hub/runtime`, field
`OPS_HUB_KEY`, unless the operator chooses a different approved path.
- Record only non-secret evidence: consumer id, key creation time, storage
path, and verification result.
Exit criteria:
- Activity-core has an approved way to receive `OPS_HUB_KEY`.
- `POST /api/v2/token` succeeds for the runtime key or the selected auth path.
- A protected ops-hub read/write smoke can run without exposing the key.
Current wait reason: storing the runtime key in OpenBao requires an attended
root/sudo-capable token handoff or operator UI action.
---
### T05 - Document event payload shape and validation rules
```task
id: IHUB-WP-0022-T05
status: done
priority: high
depends_on: T02
state_hub_task_id: "4eb04a83-8eea-4cab-861c-a39f312a5bb9"
```
Document the payload shape activity-core should send to
`POST /api/v2/interaction-events`.
The document must cover:
- Required Inter-Hub fields: `widgetId`, `eventType`, `viewContext`, and
`metadata`.
- Per-event metadata fields activity-core should include.
- Entity identity fields and how to handle unknown values.
- Timestamp semantics: observed time versus submitted time.
- Severity/result vocabulary, if used.
- Idempotency or duplicate tolerance expectations.
- Privacy and secret redaction rules.
- Expected API errors and recovery behavior.
Exit criteria: `docs/contracts/ops-hub-activity-core-event-payloads.md`
exists with one example payload for each of the five event types.
Implementation note (2026-06-15): completed
`docs/contracts/ops-hub-activity-core-event-payloads.md`. It documents the
Inter-Hub request envelope, shared validation rules, idempotency expectations,
forbidden payload material, expected API errors, and one example for each
activity-core event type.
---
### T06 - Validate fallback-first intake
```task
id: IHUB-WP-0022-T06
status: wait
priority: medium
depends_on: T01
state_hub_task_id: "38b54991-bed2-4f9d-bede-bea35821b1ef"
```
Before enabling per-entity Inter-Hub submission, accept and review the State
Hub fallback evidence path that activity-core already supports.
Validation path:
- Trigger or review an `ops_inventory_probe` fallback summary.
- Confirm it contains enough non-secret evidence to preserve operating
continuity while Inter-Hub submission is gated.
- Record which evidence cannot be represented well in fallback summaries and
should move to Inter-Hub first.
- Decide whether `ACTIVITY-WP-0007/T06` may close with Inter-Hub submission
explicitly deferred, or whether live Inter-Hub submission is a hard closure
gate.
Exit criteria: `docs/evidence/ops-hub-activity-core-fallback-validation.md`
records fallback evidence, gaps, and the closure recommendation.
Implementation note (2026-06-15): created
`docs/evidence/ops-hub-activity-core-fallback-validation.md`. Activity-core's
fallback sink is implemented and locally tested, but a direct State Hub query
for `event_type=ops_inventory_probe` returned no live events. This task remains
waiting on a disabled/manual activity-core probe or other live fallback
evidence before it can close.
---
### T07 - Run end-to-end Inter-Hub submission smoke
```task
id: IHUB-WP-0022-T07
status: wait
priority: high
depends_on: T03,T04,T05
state_hub_task_id: "23baee9b-d710-42c8-9a19-f936bd237444"
```
Run a controlled submission from activity-core or a fixture that uses the same
environment variables and mapping shape:
- `INTER_HUB_URL`
- `OPS_HUB_KEY`
- `OPS_HUB_WIDGET_MAPPING`
Smoke checks:
- One event per evidence type is accepted by
`POST /api/v2/interaction-events`.
- Event type enforcement rejects an undeclared event type.
- Metadata is persisted and returned by the relevant list/show endpoint.
- API rate-limit and hub-registry reads do not hit COUNT decode failures.
- Failure mode is clean when config is absent, matching activity-core's current
gated behavior.
Exit criteria: non-secret smoke evidence is recorded and activity-core can
enable the Inter-Hub sink in its controlled environment.
Current wait reason (2026-06-15): depends on live manifest/widgets from T03,
runtime key provisioning from T04, and activity-core implementing actual
Inter-Hub submission beyond its current deferred sink.
---
### T08 - Coordinate ACTIVITY-WP-0007 closure handoff
```task
id: IHUB-WP-0022-T08
status: wait
priority: medium
depends_on: T06,T07
state_hub_task_id: "4a7ed0ed-552e-42d3-a90f-1efd52b8851e"
```
Send a closure decision or handoff back to activity-core.
The handoff must state:
- Whether `ACTIVITY-WP-0007/T06` can close on fallback evidence with explicit
Inter-Hub deferral.
- Or whether live Inter-Hub submission is now ready and should be required
before closure.
- Which config values activity-core needs, naming only secret references and
never secret values.
- Which widgets/event types are active.
- Which smoke evidence was collected.
Exit criteria: activity-core has enough non-secret evidence and configuration
shape to close or unblock `ACTIVITY-WP-0007/T06`.
Current wait reason (2026-06-15): closure handoff depends on either a live
State Hub fallback event plus an explicit Inter-Hub deferral decision, or a
successful Inter-Hub submission smoke.
## Exit Criteria Summary
| Task | Deliverable | Status |
|---|---|---|
| T01 | `docs/research/ops-hub-evidence-intake-current-state.md` | done |
| T02 | `docs/contracts/ops-hub-activity-core-mapping.md` | done |
| T03 | Active ops-hub manifest and seed widgets | wait |
| T04 | `OPS_HUB_KEY` stored outside Git and smokeable | wait |
| T05 | `docs/contracts/ops-hub-activity-core-event-payloads.md` | done |
| T06 | `docs/evidence/ops-hub-activity-core-fallback-validation.md` | wait |
| T07 | End-to-end Inter-Hub submission smoke evidence | wait |
| T08 | activity-core closure handoff | wait |
## Binding Principles
- Governed vocabulary first: every event type must come from an active
manifest before activity-core sends it.
- Secret custody stays out of Git: workplans may name paths and fields, never
key values.
- Fallback before activation: State Hub fallback evidence remains the safety
path until the Inter-Hub widget/API-key path is verified.
- Aggregate first, split later: use aggregate widgets when entity identity is
ambiguous, then move to per-entity widgets only when stable.
- No manual DB access by default: use the documented v2 API unless the operator
explicitly approves a fallback.