# 2026-05-20 — Universe Onboarding PR5 Cold-started PR5 from `/plans/universe-onboarding-pr5.md`. Both open design questions confirmed: 1. **GET vs POST for /me/install**: GET. Parent-plan-locked, matches OAuth `/me`-style endpoints. Mint side-effect documented in `meInstall`'s doc comment so future readers don't expect REST-idempotent semantics. 2. **Empty authorized worlds → 200 vs 403**: 200 + `worlds: []`. The user IS authenticated; absence of installable worlds is authz-config emptiness, not auth failure. The plugin layer needs the distinction so it can surface "no worlds authorized for your identity" vs an auth-retry. ## Changes - `tools/demarkus-broker/internal/broker/issuer.go` — `MintFiltered(ctx, claims, keep)` added. Body of `Mint`'s authorization+iteration moved here; `Mint` is now a back-compat wrapper `MintFiltered(ctx, claims, nil)`. `keep` runs AFTER `authorizedWorlds` so the predicate input is "worlds this identity is already permitted to use." All-filtered-out returns ErrNotAuthorized (same sentinel as "zero authorized worlds in the first place") so the HTTP layer maps both branches to one response shape. - `tools/demarkus-broker/internal/broker/issuer_test.go` — six new tests around MintFiltered: nil-predicate ≡ Mint regression guard, predicate skip + Secret assertion, reject-all → ErrNotAuthorized + no-Secret-touch, partial-failure passthrough, ErrNotAuthorized-short-circuits-before-predicate (panic predicate proves the contract), predicate-sees-authorized-worlds-only. - `tools/demarkus-broker/internal/broker/install.go` (new) — `meInstall` handler, `installResponse`/`installWorld` structs, `toInstallWorlds` joiner, `lookupPublicURL`. PublicURL filter passed to MintFiltered as `func(w *WorldConfig) bool { return w.PublicURL != "" }` so worlds without a PublicURL get no issuance record. Cache-Control + Pragma headers set BEFORE `writeJSON` so they actually land on the wire. Partial-mint shape matches `/auth/callback`'s `partialFailure` field for consumer consistency. - `tools/demarkus-broker/internal/broker/install_test.go` (new) — 12 tests covering happy path (1 world), multi-world ordering (declaration order pinned), PublicURL-less filtered out + no Secret churn, no-bearer 401, bad-bearer 401, empty-worlds → 200+[], all-worlds-filtered → 200+[], unverified email → 403, partial mint → 200 + partialFailure flag, hard failure → 500, Cache-Control/Pragma headers, broker-signed bearer accepted (regression guard against PR4's compositeVerifier dispatch). - `tools/demarkus-broker/internal/broker/server.go` — `mux.Handle("GET /me/install", authedSubject(s.meInstall))` registered in `Routes()`. Same composition as `/tokens` routes (requireAuth → subjectRateLimit). - `tools/demarkus-broker/main.go` — package-doc Current-scope comment updated to mention `/me/install`. - `deploy/helm/demarkus-broker/README.md` — new "Endpoint surface" section with a table of every broker route + auth/rate-limit/notes. `/me/install` subsection documents the response shape, the PublicURL-filter rule, the 200-empty empty-worlds policy, the partial-failure shape, and the broker-signed/IdP-signed bearer-accept story. ## Design decisions that landed differently from the plan - **MintFiltered's `keep` runs AFTER AllowConfig, not before.** Plan diagrammed it "after authorizedWorlds and before per-world mint." Tightened in implementation: the predicate must NEVER run on an unauthorized caller (the new `TestMintFilteredRejectsUnauthorizedBeforePredicate` test pins this with a `panic()` predicate that fires loudly if the contract regresses). Reasoning: a buggy predicate could otherwise be coaxed into running against fabricated WorldConfig pointers if the unauthorized-short-circuit moved. - **`toInstallWorlds` tolerates empty `PublicURL` lookup misses.** A world that vanishes from `cfg.Worlds` between MintFiltered's iteration and the response-build (operator hot-reload mid-request) would land in the response with `publicURL: ""`. Two options: (a) drop the row, (b) surface it with empty URL. Picked (b) — config-time race is a logging-and-metrics story; dropping a row would also need a partial-failure flag set, doubling the failure-mode surface for an edge case that doesn't happen in production wiring (cfg is loaded once, never mutated). The consumer (`tools/demarkus-join`, PR6) treats empty `publicURL` as "skip this row" so the user sees "X out of Y worlds installed." - **`fakeVerifier` already had a `verifyFn` hook from PR4.** Was tempted to add a new helper for "verifier that errors on VerifyIDToken"; reused the existing hook instead. Pattern worth remembering for next time: the test-double surface already has the lever; check first. ## Verification - `go test -race -count=1 ./internal/broker/` → green (~6s; +18 new tests: 6 MintFiltered + 12 meInstall) - `helm unittest .` (broker chart) → 72/72 pass, unchanged from PR4 baseline (no chart changes — README is the only doc surface that moved) - `bash pre-commit.sh` → format, vet, lint clean across protocol/server/client/tools ## Scope estimate vs actual | Step | Plan code | Actual code | Plan tests | Actual tests | | --- | ---: | ---: | ---: | ---: | | 1. Issuer.MintFiltered | 40 | 42 | 80 | 209 | | 2. meInstall handler | 80 | ~115 | 200 | ~365 | | 3. Route registration | 5 | 8 | 0 | 0 | | 4. Documentation | 30 | ~72 | 0 | 0 | | **Total** | **~155** | **~237** | **~280** | **~574** | Tests came in roughly 2× the estimate — the partial-failure + Cache-Control + broker-signed-bearer matrix is denser than the plan accounted for, and the install_test.go file isolates fixtures (installTestConfig, installTestConfigTwoWorlds) that the plan assumed would be shared with issuer_test.go. Worth it: every test covers a distinct invariant that would be expensive to debug in PR6/PR7 if it regressed silently. ## Next PR5 ready to open. Fritz handles the commits (per `/patterns.md` "Fritz handles all commits himself"). Branch is `feat-tools-broker-me-install`. After review + merge: - **PR6** (`tools/demarkus-join` binary) consumes the JSON shape PR5 emits. Wire contract: `{name, publicURL, label, accessToken, expiresAt}` per world; PR5 pins this in `install_test.go` so a future field-rename ripples through tests. - **PR7** (plugin slash commands + kind Stage 5) drives PR6. - **PR8** (docs) is the operator-facing universe-onboarding write-up. ## Touch points worth remembering for PR6 - The plugin layer must NOT treat `worlds: []` as an auth error. The broker returns 200; the plugin surfaces "no worlds authorized for this identity." - A `partialFailure: "one_or_more_worlds_failed"` field is informational, not fatal — install the worlds that did come through and tell the user some are missing. - `accessToken` is a one-time disclosure. Once it lands in `tools/demarkus-join`, write it to the world client config and discard the in-memory copy; the broker can't reissue it (raw material is unrecoverable). - Cache-Control + Pragma headers are set by the broker, but the consumer should also avoid logging the response body — bearer tokens leak via accidental error-path stack traces. ## Broker MCP Gateway — kickoff (Pre-Flight 0 + 1) Same-day pivot from PR5 (#141 shipped) into the gateway plan. Plan v3 read top-to-bottom; only Open Question 1 is settled, #2-#8 carry forward with their documented leans. ### Pre-Flight 0 — hoist `client/internal/fetch` + `merge` to public Branch `feat-client-hoist-fetch-public`. Mechanical relocation: - `client/internal/fetch/fetch.go` → `client/fetch/fetch.go` - `client/internal/merge/{merge,diff3,merge_test,diff3_test}.go` → `client/merge/...` - Import paths updated in 9 consumers (broader than the plan's "3 cmd dirs" — also `demarkus-agent`, `fedcrawl`, `graphstore`). The grep before the move caught this; worth noting in the plan-vs-reality column. Verification: - `go test -race -count=1 ./...` inside `client/` → green; same package list as baseline plus `client/fetch` (no test files) and `client/merge` (tests survive the move intact). - `bash pre-commit.sh` → format/vet/lint clean across protocol/server/client/tools. - `git status` shows 5 renames + 9 modified imports. Git's rename detection caught all 5 as zero-diff renames. Why this matters for the gateway: `tools/demarkus-broker` lives in a separate Go module under monorepo `replace` directives. Go's internal-package rule would have blocked the broker from importing `client/internal/fetch` and `client/internal/merge` directly. The hoist is the unlock — broker's per-tool handlers in Slice 2/3/6 can now call `fetch.Client` and `merge.Candidate` without copy-paste. Branch waiting on Fritz for commit + PR review (per `/patterns.md` "Fritz handles all commits himself"). ### Pre-Flight 1 — Streamable HTTP support in mcp-go v0.44.0 Decision: **Streamable HTTP, via mcp-go's `server.NewStreamableHTTPServer`.** No transport swap or in-house implementation needed. Evidence: `~/go/pkg/mod/github.com/mark3labs/mcp-go@v0.44.0/server/streamable_http.go` exports `StreamableHTTPServer` as a first-class production type (not just a test helper). It implements `http.Handler` — drop-in for whatever HTTP mux the broker's MCP listener uses, and the default URL path is `/mcp`, exactly the shape Slice 1 calls for. Three usage modes are documented (`.Start(":8080")`, `http.Handle("/path", handler)`, or `ServeHTTP` directly). Targets the [2025-03-26 transport spec](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). One caveat from the package doc: "The current implementation does not support Stream Resumability." Irrelevant here — the plan's Out-of-Scope already excludes long-lived subscriptions and server-initiated events; tool calls are JSON-RPC request/response. Polling is the model. Slice 1 shape this implies: - `mcpGateway` wraps `server.NewStreamableHTTPServer(s.mcpServer)` and mounts it on a fresh `http.ServeMux` separate from the existing management API. - `gatewayAuth` middleware composes around the StreamableHTTPServer (standard `http.Handler` chain): id_token verification + per-subject rate-limit happen before the handler ever sees the request body. - OAuth metadata endpoints (`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`) register on the same mux, plain `mux.HandleFunc`. Stdio-mode `client/cmd/demarkus-mcp` keeps using `ServeStdio` — unchanged. Two transports, one tool surface, eventually. ### Next Branch out of Pre-Flight 0 once merged, cut `feat-tools-broker-mcp-gateway-foundation` for Slice 1.