Synthesis of three investigations (hydramancer lens #488, library lens #487, authz lens #489). This is the consolidated, decisive findings + implementation plan.
The Cyborn pipeline now works end to end: Cyborn submits a build to Perforce, hydraperforcewatcher zips and publishes it to the mirror, POST /api/v1/builds/notify fires, and hydraexperiencelibrary registers it as a development build against the gallo-romeins-museum experience. What Cyborn still cannot do is see their experience's state or drive its lifecycle (stage, promote, rollback, pause). Today an operator does every one of those steps with the library's admin token. This work gives Cyborn a self-service, org-scoped view and a scoped set of lifecycle controls, delivered through the existing hydramancer creator portal — reusing the exact sign-in + privileged-proxy pattern already shipped for the "Get Perforce access" panel. Now is the moment because the plumbing is proven and the only missing piece is a safe, org-scoped control surface.
hydramancer (the creator portal) already has the whole front half:
/experience: GET /experience/login -> iamnim /login?redirect_uri=.../experience/authed; GET /experience/authed stores the token in the iamnim_session cookie (HttpOnly/Secure/Lax/24h); GET /experience/logout clears it. Session read by iamnimSession() from cookie / X-Iamnim-Session header / ?token=. Files: internal/api/handlers_experience.go:14-50, handlers_provision.go:59-67.internal/iamnim/client.go: Me(session) (GET /api/me), Memberships(session) (GET /api/me/memberships -> {organization_slug, organization_name}).POST /api/v1/provision/perforce (server.go:50) -> handleProvisionPerforce (handlers_provision.go:16-55): require session, forward downstream as X-Iamnim-Session, portal holds no privileged creds, upstream status/body streamed back. Upstream URL from config (config.go:17-20, env HYDRAMANCER_PROVISION_PERFORCE_URL); empty URL disables the panel.experience.html:148-171 gated on .CanProvision, backed by experienceData{SignedIn,Email,Orgs,CanProvision} (server.go:71-94), with fetch+render JS at experience.html:445-480.hydraexperiencelibrary already has the whole back half — a full lifecycle API and admin UI:
requireAuth, server.go:124-155): GET /api/v1/experiences, GET /api/v1/experiences/{name}, GET .../builds, PUT, POST .../promote ({"to":"staging"|"production"}, empty = auto-detect, handlers.go:197-274), .../rollback (288-320), .../pause / .../resume / .../retire (322-371), .../stream, POST /api/v1/builds/notify.GET /pipeline/health?experience= (handlers_pipeline.go:264-326, last 10 builds) and GET /experiences/{name}/builds (handlers.go:453-474). The Experience record carries only int pointers DevelopmentBuild/StagingBuild/ProductionBuild/PreviousBuild (store.go:25-28); build detail lives in a separate builds.yaml store (build.go).RequireWebAuth cookie session, server.go:99-109): GET /admin, GET /admin/experiences/{name}, POST /admin/experiences/{name}/{promote,rollback,pause,resume,retire,stream}.Experience.Developer and Experience.Owner (store.go:17-43); gallo-romeins-museum has developer="cyborn", owner="gallo-romeins-museum".The gap. The library API authorizes on a single shared admin bearer token (middleware.go:10-24) — all-or-nothing, no org/tenant awareness anywhere. handleListExperiences (handlers.go:17-24) returns the entire catalog unfiltered. The admin web UI is the same token behind a cookie. There is no per-developer/owner code path in the library at all.
Decision: Option A — enforce org scoping in a hydramancer proxy that holds the library admin token, mirroring handleProvisionPerforce. Leave the library API unchanged for the initial rollout. This is unanimous across all three lenses. Rationale:
X-Iamnim-Session, exactly like hydraperforceprovision) is the correct durable end state and should be the follow-up once a second creator org exists — but it is a larger change to a live service and is not justified for a single-tenant MVP.The one inversion that makes this harder than Perforce — and the scoping rule. In the Perforce proxy the client names its own org and receives a depot named after it, so naming a foreign org buys the caller nothing (hydraperforceprovision/internal/api/handlers.go:21-92, IsMember exact-match at internal/iamnim/client.go:63-75). With experiences, the request names an experience {name}, and the owning org is a property of the stored record, not of the request. Therefore the proxy must never trust any org field from the client. The rule:
Which experiences may this creator touch = experiences whose
developer(control) — orowner(read-only candidate) — org field is one of the caller's iamnim membership slugs.
Concretely, every action path is: (1) authenticate the session with Me() (fail closed on error); (2) resolve the caller's membership slugs via Memberships(); (3) load the target experience from the library first and read its developer; (4) allow only if that org is in the caller's slug set; (5) only then forward to the library with the admin token. List views filter the full catalog down to the caller's orgs — they never return it whole.
Making org identifiers match reliably (a real correctness risk, not cosmetic). experience.developer is free-text operator-set YAML compared against Pantheon slugs. The known mismatch: the experience carries developer="cyborn" and owner="gallo-romeins-museum", while the Perforce/depot normalization uses galloromeinsmuseum (see memory: Gallo-Romeins depot galloromeinsmuseum, user koen). For the MVP the control field is developer="cyborn" and the iamnim org slug is cyborn — these match exactly, so Cyborn works today. But before scoping is enabled for any second org, we must: (a) apply one canonical normalization (lowercase, strip non-alphanumerics) on both the membership slug and the experience org field before comparison, and (b) add a startup validation that every experience's developer/owner resolves to a known org slug, logging mismatches. Treat experience.developer == iamnim organization_slug as a documented invariant until (b) enforces it.
Read (MVP): list the experiences their org owns/develops; per-experience status and build state (draft/development/staging/live, the DevelopmentBuild/StagingBuild/ProductionBuild pointers, and recent build history via pipeline/health?experience=).
Lifecycle actions to expose (Phase 2), all org-scoped:
POST .../promote {"to":"staging"}) — staging nodes only, safe.POST .../rollback) — reverts to the previous build, safe and reversible.POST .../pause / .../resume) — safe operational toggles.Gated / operator-only:
{"to":"production"}) invokes reprovisionProduction, which pushes to live public venues (handlers.go:246-263). Keep this operator-gated for the MVP; a later phase may allow it behind an explicit typed confirmation + audit entry. Justification: production promotion changes what the public sees in a venue; a self-service creator should not trigger that unsupervised on first release.developer, owner, districts, watch_name, auto_promote, exe_path, release_path). A creator editing them could re-point scoping or change what gets executed. Hard out of scope.Explicitly out of scope: creating or deleting experiences; editing exe_path / release_path / watch_name / districts / auto_promote; any cross-org visibility.
hydramancer — config
ExperienceLibrary{ base_url, admin_token } block to config.go, with env overrides HYDRAMANCER_EXPERIENCE_LIBRARY_URL and HYDRAMANCER_EXPERIENCE_LIBRARY_TOKEN. Empty base_url disables the panel (same convention as HYDRAMANCER_PROVISION_PERFORCE_URL). Token is server-side only, never rendered.hydramancer — iamnim client
IsMember(session, orgSlug) to internal/iamnim/client.go (copy the exact-match scan from hydraperforceprovision/internal/iamnim/client.go:63-75) plus a normalized-compare helper.hydramancer — proxy routes + scoping middleware (handlers_experiences.go)
GET /api/v1/experiences (scoped): require session -> Memberships() -> call library GET /api/v1/experiences with the admin token -> filter to experiences whose normalized developer/owner is in the caller's slug set -> return the filtered list (+ status/build fields).GET /api/v1/experiences/{name} (scoped): load from library, org-check on the record, return or 403.POST /api/v1/experiences/{name}/{action} where action ∈ {promote-staging, rollback, pause, resume}: require session -> load the experience from the library first -> derive the authorizing org from experience.developer (never from the request body) -> IsMember check -> forward to the matching library route with the admin token -> stream status/body back. Reject any action not in the allow-list (this is what keeps production-promote / retire / PUT out). Reuse iamnimSession() and writeProvisionError().hydramancer — UI
experience.html, data-driven exactly like the Perforce panel (a CanManageExperiences flag on experienceData, populated when the library config is present and the user has ≥1 experience). List the org's experiences with status + build info and the scoped action buttons; production-promote and retire are absent (not merely disabled). Reuse the existing fetch/render JS shape at experience.html:445-480.Optional library-side hardening (Phase 3, Option-B-lite): add a developer= query filter to the library's GET /api/v1/experiences as a defense-in-depth second fence, so even a proxy bug cannot list foreign experiences. This is additive and does not change the auth model.
Tests + docs: unit tests for the scoping middleware (member allowed, non-member 403, unlisted experience filtered out, foreign org name in body ignored, iamnim error -> deny); a runbook in hydramancer documenting the config, the scoping invariant, and the developer == org slug normalization rule (memory requires a runbook to back any operational memory entry).
experience.developer is free-text YAML vs Pantheon slugs; gallo-romeins-museum/galloromeinsmuseum vs cyborn. Mitigation: one canonical normalization on both sides + startup validation that every experience org resolves to a known slug; document developer == org slug as an invariant. Cyborn's developer="cyborn" matches the slug cyborn today, so the MVP is safe.exe_path/release_path are correct — creator-visible but creator-uneditable (operator-only fields). If a build is mis-packaged, promotion to staging surfaces it safely before any operator touches production.builds/notify keys on watch_name via FindByWatchName (store.go:221-229) and fans one build to every experience sharing it. This is inbound-only and unchanged by this work, but reinforces that watch_name must stay operator-only (it is, under "out of scope").Estimate: ~2-3 engineering days for Phases 1-2 (the pattern is copy-adapt from the Perforce proxy; the library needs no changes), plus ~1 day for tests, runbook, and the optional library developer= filter.
IsMember; GET /api/v1/experiences (scoped, filtered) and GET .../{name}; the "Manage your experiences" panel showing each experience's status and build state. No mutations. Ships Cyborn immediate visibility with near-zero blast radius.POST .../{name}/{promote-staging,rollback,pause,resume} behind the action allow-list and org check, with the audit log. Production-promote and retire stay operator-only.developer= filter (defense in depth); startup org-slug validation; and, once a second creator org exists, migrate to Option B (library-side iamnim auth via forwarded X-Iamnim-Session, mirroring hydraperforceprovision) as the long-term auth model.Related: this synthesizes and supersedes the per-lens issues #487, #488, #489. No code, deploy, or live-state change was made.
Comment posted to #490 (HTTP 201, now 1 comment by creator-portal-assessment). Synthesis below is the exact text posted.
Synthesis of five tool-landscape investigations (build-delivery, perforce, experience-venue, observability, support-docs). This comment does not change the existing lifecycle plan — it widens the scope around it and records the negative space.
Cyborn's mental model is a delivery loop: submit to Perforce -> package/upload -> publish to mirror -> registered build -> stage/promote -> live on fluffy -> something breaks -> report it. #490 lights up only the middle. Keep experience lifecycle as the unchanged anchor panel and reframe #490 as a single org-scoped creator dashboard. The marginal cost per added panel is small once the #490 scoping middleware exists.
| # | Panel | Source | Scoping today | Priority | Effort |
|---|---|---|---|---|---|
| 1 | Experience lifecycle (anchor) | hydraexperiencelibrary | needs #490 proxy (scope on developer) |
P0 | per #490 |
| 1a | "Runs at" (Districts) + "Live now?" (experiences/live) |
hydraexperiencelibrary | rides record proxy already loads | P0 | XS |
| 2 | Report a problem | hydraissue POST /issues |
filing needs no upstream change; pure proxy | P1 | S |
| 3 | Build pipeline / source control ("did my submit land?") | hydraperforce | all-tenants HTML, no per-org JSON read; needs scoped GET /agents/{id}/state + org->agent map; don't iframe /admin |
P1 | S–M |
| 4 | Build & delivery status (preflight/package/upload/download) | hydraunrealengine-server | single token AND no org key (Client=hostname); needs reporter to emit org key first |
P1 | M + CLI change |
| 5 | Live / run status (body online, my experience active) | hydrabodystatus + hydrastreamingmonitor | no org field; cross-join experience->district/venue + field allow-list | P2 | M–L |
| 6 | Docs & getting started | hydrabooks | public + project-keyed; no proxy | P2 | S |
| 6a | Venue context (address/bandwidth/rider) | hydravenues | public reads; NOT its ?organization= filter (owner axis) |
P2 | S |
| 7 | "My issues" list | hydraissue | no owner filter; must filter proxy-side; never link public index | P2 | M |
Rejected: hydrapipeline (operator SRE, leaks infra inventory); hydramirror (infra artifact store); hydraperforcewatcher (push-only, surfaces via hydraperforce); hydratransfer (download URL already on job record; admin all-tenants); hydraunrealengine CLI + hydrarelease (run on Cyborn's box — link-out only).
The single-token/all-tenants gap recurs in hydraperforce, hydraunrealengine-server, hydrabodystatus, hydrastreamingmonitor, hydraissue. It is one problem. The #490 org-scoping proxy (fail-closed: iamnim Me() -> membership slugs -> load record -> authorize on stored org field, never the request -> filter lists) is reusable middleware — build once, add panels behind it. Two encoded nuances: the creator axis is developer (Cyborn) not owner (museum); tools with no org field need a cross-service join or a small upstream org-key change before they can scope.
Retitle to "Creator dashboard…"; make current lifecycle plan panel #1 (keep its Option-A proxy/scoping/allow-list intact as the shared middleware); add XS Runs-at/Live-now to Phase 1; add P1 panels 2–4 (only hydraperforce + hydraunrealengine-server need small upstream additions, rest pure proxy); add P2 panels 5–7; record the rejected set with reasons; state that backend additions are the exception, not the rule.
Files cited across buckets: hydraexperiencelibrary internal/store/store.go, internal/api/handlers.go/handlers_pipeline.go; hydraperforce internal/server/server.go, pkg/state/state.go; hydraunrealengine-server internal/server/{server.go,store.go} + hydraunrealengine pkg/report/report.go; hydrabodystatus internal/store/store.go, internal/api/*; hydrastreamingmonitor internal/*/handler.go; hydravenues internal/api/server.go, internal/store/venue.go; hydraissue internal/store/issue.go, internal/api/server.go; hydrabooks internal/api/handlers_api.go. Rejected: hydrapipeline, hydramirror, hydraperforcewatcher, hydratransfer, hydrarelease, hydraunrealengine CLI.
Broaden #490 from "experience management" to a creator dashboard on hydramancer
Synthesis of five tool-landscape investigations (build-delivery, perforce, experience-venue, observability, support-docs). This comment does not change the existing lifecycle plan — it widens the scope around it and records the negative space. All claims are grounded in the repos under review; no live change was made.
1. Reframe
The lifecycle plan in #490 is right, but it is one panel of a bigger job. Cyborn's mental model is a delivery loop: submit to Perforce -> package/upload -> publish to mirror -> registered as a build -> stage/promote -> live on a venue body (fluffy) -> something breaks -> report it. #490 today only lights up the middle (stage/promote/rollback/pause). Everything upstream ("did my submit land? did my package build? where's the download?") and downstream ("is it actually running at the museum right now? who do I tell when it crashes?") is still invisible or operator-relayed. The proposal: keep experience lifecycle as the anchor panel (unchanged) and reframe the issue as a single org-scoped creator dashboard where Cyborn sees their whole loop and holds the few controls they need. Crucially, almost every additional panel reuses the exact org-scoping proxy #490 already designs for the library — so the marginal cost per panel is small once that middleware exists.
2. Recommended panels, prioritized
Experience.developer)Experience.Districts(which venues it's assigned to) and a live indicator viaGET /api/v1/experiences/liveintersected with the owned experience.POST /api/v1/issues,.../comments)session.experience/venue auto-stamped andreporter= iamnim identity; stampcustom_fields.owner_org.api_tokenhydraperforce.experiencenet.com)GET /api/v1/agents/{agentID}/state+ anorg->agent_idmap (cyborn/galloromeins ->galloromeins) in the proxy. Do NOT iframe/admin(leaks every venue).hydraunrealengine.experiencenet.com)GET /api/v1/jobs,/jobs/{id}.Clientis the workstation hostname (report.go:132), not an org. Needs a scoping key first (reporter emits org/developer, or a hostname->org map) before proxy filtering works..../bodies) + hydrastreamingmonitor (sessions/stream)end_reason; body online + basic health.Bodyhas no owner/org field at all — must cross-join owned-experience -> itsdistricts/venues-> filter bodies/sessions by location, then field-allow-list the payload (strip GPU/power/other tenants'StreamSessions, raw body logs). Scoping key is experience->developer, not nodeOwner(that's the museum, not Cyborn).books.experiencenet.com)GET /api/v1/books/{project}?format=html,/search.hydravenues.experiencenet.com)?organization=filter — that keys on venue-owner org (museum), not developer (Cyborn)./docslink-out)GET /{$}) renders every tenant's issues. Must filter server-side in the proxy by the stampedowner_org/reporter_email; never link to the public index.owner_orgfield +owner=filter upstream)Considered and REJECTED (record the negative space):
index; a creator wants the resulting build record + download URL, not the mirror. Do not surface.releases.experiencenet.com) belongs in the portal, not a hosted panel.3. The cross-cutting authorization theme (the central architectural point)
Almost every backend above is single-token / all-tenants — the exact gap #490 already names for the experience library recurs in hydraperforce, hydraunrealengine-server, hydrabodystatus, hydrastreamingmonitor, and hydraissue. That is not five separate problems; it is one. The org-scoping proxy #490 introduces (fail-closed: authenticate with iamnim
Me(), resolve membership slugs, load the target record, authorize on the stored org field never the request, filter list views to the caller's orgs) is a reusable middleware. Build it once for the library, then every additional panel is "add a scoped route behind the same fence." Two scoping-key nuances the middleware must encode, because they trip up naive filtering:developer(Cyborn), notowner(Gallo-Romeins Museum). NodeOwner, venueOrganizationID, and Perforce depot owner all key on the customer org and are the wrong axis for a creator. The reliable link isexperience.developer == iamnim org slug, then experience -> its districts/venues/watch-agent for anything that lacks a developer field.Client= hostname). Those need either a cross-service join (experience -> location) plus payload allow-listing, or a small upstream change to emit an org key, before they can be scoped. Flag each as blocked-on-scoping, not shippable read-as-is.4. Suggested concrete adjustments to #490
Experience.Districts) and "Live now?" (GET /api/v1/experiences/live) — both ride the record the proxy already loads. (XS)POST /api/v1/issues, session/experience auto-stamped): pure proxy, no upstream change, shippable independently of and before the lifecycle work. Note the "My issues" read view is P2 and needs proxy-side owner filtering (never the public index).GET /api/v1/agents/{agentID}/stateJSON endpoint (data model already has json tags) plus anorg_slug->agent_idmap in the proxy. Do not iframe/admin./docslink-out).owner_orgfield) need small upstream work; everything else is pure proxy, public read, or link-out.5. Phasing