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/installand/auth/callbackreturn identity + allowed-worlds only. NoaccessToken.- 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
requireAuthreading 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 firstmark_*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
dispatchWithAuthretry loop handles the once-per-(user, world) first-call lag). - No client-side token shape change. The CLI's
~/.mark/tokens.tomland the Obsidian plugin'sDEMARKUS_AUTHenv 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 inMint/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
accessTokenfrom/me/installand/auth/callbackresponse 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
FirstMintMaxAttempts6 → 2,FirstMintMaxBackoff2s → 2s (already 2s),FirstMintInitialBackoffkeep 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'spaths/operationson 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.