# Broker — Stable, Internal-Only Demarkus Tokens ## Context Today the broker rotates per-world demarkus tokens on every `/auth/callback` and `/me/install` call. Combined with kubelet's Secret-projection lag (~30-40s on GKE), this causes "broker: world rejected freshly-minted token after N attempts" failures on the user's first `mark_*` call after every join. 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. So the data-plane access guarantee is: SSO rejection at the broker's `requireAuth` = no path to the world. Demarkus tokens never need to leave the broker. ## Design Demarkus tokens become an **internal broker implementation detail** for the knowledge-system flow. The client never sees them. - `/me/install` and `/auth/callback` return identity + allowed-worlds only. No `accessToken`. - The Issuer becomes idempotent: lookup-before-mint by `(subject, world)`. Mint only when no non-expired issuance exists. - Token TTL flips long (1 year default). The broker rotates internally via the sweeper if/when it needs to; the user never notices. - Access changes are checked on every broker request via `requireAuth` reading the OIDC bearer's current claims. If a user has lost access to a world, the broker rejects before dispatching — no token rotation required for revocation. - First-time `(subject, world)` provisioning still hits the kubelet-lag floor on the very first `mark_*` call. That's the one moment "your access is being provisioned, wait a few seconds" is acceptable; subsequent joins do not retrigger it. ## Why this is smaller than the probe/sidecar designs - No new protocol surface on the world. - No probe-during-mint logic (the existing `dispatchWithAuth` retry loop handles the once-per-(user, world) first-call lag). - No client-side token shape change. The CLI's `~/.mark/tokens.toml` and the Obsidian plugin's `DEMARKUS_AUTH` env var keep working for direct-QUIC sou flows; only the knowledge-system flow stops returning tokens. - No "stable across joins requires raw token recovery" problem, because the client never had a reason to hold the raw token in the first place. ## Slices ### Slice 1 — Idempotent issuer - New method on the issuances store: lookup non-expired entry by `(subject, world)`. - `mintForWorld` (or its caller in `Mint` / `MintFiltered`) consults the lookup before generating a token. If a live issuance exists, reuse it and skip both the token-generation and the world-side Secret write. - Default TTL bumped to long-lived (1 year) — knob preserved for ops who want shorter. - Tests: first call mints; second call for same (subject, world) returns existing; expired entry triggers re-mint; concurrent first-calls for same pair coalesce (singleflight if not already there). ### Slice 2 — Strip raw tokens from response bodies - Drop `accessToken` from `/me/install` and `/auth/callback` response structs. - Replace with identity + worlds-allowed list (broker already has both). - Tests: verify response shape; verify nothing in the plugin/CLI knowledge-system path was depending on the field. ### Slice 3 — Shrink dispatchWithAuth retry budget - `FirstMintMaxAttempts` 6 → 2, `FirstMintMaxBackoff` 2s → 2s (already 2s), `FirstMintInitialBackoff` keep 500ms. - Total budget shrinks from ~16s to ~2.5s. With Slice 1, the only path that hits this loop is the very-first-call-for-a-new-(user, world) and sweeper-rotation races; both are bounded by kubelet sync (~30s worst case), so a short budget surfaces real problems faster than the old long budget masked them. Slices 1+2+3 can ship in one PR if the surface stays small; split if review is easier that way. ## Open items - **Access-change propagation, not revocation.** Today the issuer mints a token with fixed scope (paths + operations baked into the world's tokens.toml entry at write time). If the user's allowed paths change in the broker's `WorldConfig`, the world doesn't see the new scope until something rewrites the entry. Decide whether: (a) `requireAuth`-claims-check is enough (broker rejects out-of-scope requests before dispatch, world's loose scope doesn't matter); or (b) the broker should rewrite the world entry's `paths`/`operations` on detected scope change, keeping the same hash. Likely (a) for this slice, (b) as a follow-on if scope drift becomes a real concern. - **Existing issuances on the live cluster.** All current users already have issuances from the old "rotate every call" path. Slice 1's lookup-first sees them, reuses them, no migration needed. The first call after Slice 1 ships is a no-op for everyone. - **Background sweeper interaction.** Sweeper currently sees issuances with short TTLs; with long TTLs it'll see almost none. Confirm Sweeper still has a reason to exist (orphan-detection, broker↔world drift reconciliation) or whether it can be simplified. ## Status - 2026-05-27 — Plan drafted, awaiting Fritz review on slice ordering. PR #158 (fsnotify watcher) is the prerequisite and is open.