soul.demarkus.io/plans/broker-stable-mint.md/v4 draft reader meta

Broker — Lazy Token Provisioning + Cache-Stable Retries

Context

Today the broker rotates per-world demarkus tokens on every /auth/callback and /me/install call AND on every 401 retry inside dispatchWithAuth. Combined with kubelet's Secret-projection lag (~30-40s on GKE), the result is ~20 tokens written to the world's tokens.toml in 78 seconds during one user session, almost all of which fail before kubelet propagates any of them, producing the user-visible "broker: world rejected freshly-minted token after N attempts" failure.

The fsnotify watcher (PR #158) reduced the world-side reload floor from "never without SIGHUP" to "kubelet sync interval," but propagation lag is still the bottleneck.

Network shape (confirmed 2026-05-27): in knowledge.demarkus.io, world-a is not exposed outside the cluster. The broker is the only public ingress. Combined with requireAuth re-validating the OIDC bearer on every MCP request, this means SSO removal at the IdP = no path to the world's data plane. The broker's per-world demarkus tokens can therefore be a pure internal implementation detail; the client never needs to hold them.

The broker structurally cannot return the same raw token twice (issuer.go:40-43: "Raw token material is never recorded here"). So "tokens don't change on next join" is satisfied not by stable-token-recall but by not minting at join time at all.

Design

Two changes, both small, both targeted at the bug they fix:

1. Lazy provisioning

/me/install and /auth/callback stop calling Issuer.Mint*. They become identity-confirm endpoints: verify the bearer, list the worlds the caller passes worldAllows against, return {email, worlds: [...]}. No tokens in the response.

Minting moves to the place it has to happen anyway: the dispatch path's sessionCache miss in dispatchWithAuth. The first mark_* call to a given world from a given broker pod mints, writes the world Secret, populates the cache. Subsequent calls cache-hit.

Consequence: kubelet-lag is hit at most once per (broker-pod, user, world) triple — only on the first dispatch after a cold cache. Pod restarts re-warm lazily on first call. For HPA-heavy deployments where this becomes painful, a startup pre-warm is a follow-on; not in this slice.

2. Cache-stable retries

dispatchWithAuth currently calls sessionCache.Invalidate(...) + GetOrMint(...) inside its 401 retry loop, which re-mints on every iteration. That's the cascade producing ~20 junk tokens per first-call burst.

The retry loop was meant to wait for the freshly-minted token to become recognized by the world after kubelet projects the Secret update — not to re-mint each tick. The fix is:

  • On 401 after fresh-mint: sleep the backoff, retry the same cached token. No Invalidate, no re-mint.
  • On 401 after cache-hit (token was cached but world rejected): a genuine rotation race — Invalidate once, mint once, retry the new token. Bound this to a single rotation per request.
  • Only after the budget exhausts entirely does the request error out.

Why this is small

  • No new protocol surface on the world.
  • No probe-during-mint, no sidecar, no encrypted-at-rest token storage.
  • No client-side token-shape changes (~/.mark/tokens.toml and DEMARKUS_AUTH keep working for the direct-QUIC personal-soul flow; only the knowledge-system endpoints change).
  • No data migration: existing issuances on the live cluster are still consulted by Revoke/List/RotateLabel; they just stop accumulating fresh entries per-join.

Slices

Slice 1 — Lazy provisioning (smallest)

  • /me/install (install.go): replace MintFiltered call with a pure authorizedWorlds + keep filter; drop accessToken from response struct; return {email, worlds: [...]}.
  • /auth/callback device path (device.go) and auth-code path (oauth_authorize.go / wherever the new RFC 6749 handler shipped in #156 lives): same shape change.
  • Tests: handlers return identity + worlds without invoking the issuer; verify no Secret writes happen during these flows on the test k8s client.

Slice 2 — Cache-stable retries

  • dispatchWithAuth (mcp_tools_read.go:147-211) — restructure so the retry loop does NOT re-mint on every 401 from a freshly-minted token; reserve Invalidate+re-mint for one cache-hit-rejection per request.
  • The FirstMintMaxAttempts knob now governs propagation waits, not mint cascades.
  • Tests: simulate world returning N consecutive 401s on a fresh-mint token, then 200; confirm exactly one mint, N+1 dispatch attempts, success.

Slice 3 (deferred — only if needed)

  • Shrink FirstMintMaxAttempts and FirstMintMaxBackoff once Slices 1+2 land and we observe the new behavior in the field. Not blocking.

Out of scope (deliberately)

  • Access-change propagation via tokens.toml metadata rewrites. requireAuth-claims-check is the source of truth for revocation; the world's tokens.toml just needs to recognize the token, the broker decides whether it should be honored.
  • Background sweeper changes. Sweeper still has work to do for genuine rotations and orphans.
  • Pre-warming sessionCache at broker startup. Future optimization if HPA churn shows a problem.

Status

  • 2026-05-27 — Plan v2 published, simpler model than v1. Starting Slice 1.
  • PR #158 (fsnotify watcher) open, prerequisite for any field improvement but not a code-level dependency for these slices.

Follow-up cleanup (next) — dead DefaultToken knobs

After the per-world write-token model + full issuance-subsystem retirement landed (PR #164, branch refactor/broker-retire-issuance-subsystem), two WorldConfig.DefaultToken fields are now no-op knobs:

  • operationsworldWriteTokenStore.Provision hardcodes ["publish"] regardless of config (deliberate, so open-reads can't regress via a stray "read"; see #163 and the comment in world_write_tokens.go). Already documented as "kept but ignored."
  • expiresAfter — was only consumed by the removed mintForWorld. Provision sets Expires: "" (long-lived) and reads use no token, so nothing reads it now.

Both currently sit as validated-but-ignored config (chart comments updated in #164 to say so). They're a footgun for the same reason the /tokens API was: operator-facing knobs that silently do nothing.

Task: remove expiresAfter (and decide on operations) from WorldConfig.DefaultToken in config.go + its validation/defaults, the worlds[].defaultToken.* example in the broker chart values.yaml, and any config_test cases. Small standalone change — no behavior change since they're already ignored. Keep paths (still consumed by Provision).

Open question: whether to drop operations too or keep it as an explicit "this is always publish" marker. Lean toward removing both for honesty; if kept, the config struct should reject any value other than ["publish"] rather than silently ignoring it.

Status — COMPLETE (verified 2026-05-31)

All three slices plus the named follow-up shipped and verified in tools/demarkus-broker (broker tests green, run fresh).

  • Lazy per-world provisioning (#159, #164): worldWriteTokenStore.Provision mints once per world (Secret + world tokens.toml + in-memory cache); subsequent calls hit Get. /me/install reduced to a pure identity-confirm endpoint — no minting, no accessToken in the response.
  • Cache-stable retries (#159): dispatchWithAuth provisions once before the loop, then retries the same token on 401 with backoff (FirstMint* knobs). No re-mint inside the loop — the ~20-token mint cascade is structurally impossible. Regression test TestWriteHandlersInheritPropagationRaceRetry confirms exactly 2 dispatch attempts on one 401.
  • Dead-knob follow-up (#163, #165): TokenScope now carries only Paths; operations/expiresAfter removed; operations hardcoded to ["publish"], Expires empty (long-lived). rg expiresAfter → zero hits.

Design simplified beyond the plan to one long-lived write token per world (reads dispatch open to any SSO identity); deliberate, not a gap. PRs: #158, #159, #163, #164, #165.

trail
  1. soul.demarkus.io v4