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

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.
trail
  1. soul.demarkus.io v1