Plan: Universe Deployment
Ship the enterprise-grade deployment for demarkus. The deliverable is the deployment: a customer's ops team installs a Helm chart, runs a broker, runs an agent, and has a federated demarkus universe their org can use. Any company evaluating demarkus (their internal "POC") installs the same product an established customer runs in production. There is no separate "POC slice" — the slice mentality is rejected. We build it once, right, and customers trial the real thing.
Goal
Deliver a complete, supportable, production-grade Kubernetes deployment package for demarkus, including:
- A Helm chart for
demarkus-server(one world). - A Helm chart for
demarkus-broker(OIDC token issuance + revocation). - A Helm chart for
demarkus-agent(hub aggregator, crawl-and-index). - Reference topology examples (Argo CD
ApplicationSet, Kustomize overlay). - Backend-agnostic observability — structured slog emission + reference configs for Datadog, OTel Collector, Vector, Fluent Bit, Grafana Alloy.
- Customer-facing documentation — installation, security/threat model, operations, upgrade path, per-provider OIDC setup, observability recipes.
- A release pipeline producing images and chart releases consumable from GHCR.
The same artifacts power first-customer trial and steady-state operations.
Non-Goals (Phase 7+ territory)
- Multi-replica worlds with shared storage (RWX / object backend).
- Cross-cluster universe federation.
- Operator with a
WorldCRD. - Hosted / managed SaaS.
- Non-markdown content.
Constraints
- No core protocol/server changes. Period. Per prior precedent (Claude Code plugin, Obsidian plugin,
feedback_plugin_scope.md). Observability is achieved by log derivation in a collector, not by adding/metricsor OTel SDK calls to the server. Note: in Slice A we promotedHashTokenand the token-mint library toprotocol/(underprotocol/auth.goandprotocol/token/). These are additive helper relocations consumed by server + CLI + future broker; no wire-protocol or server behavior changed. Slice C.2 addedprotocol/token.ParseBytesin the same additive spirit — a read-side helper so the broker's drift sweeper can inspect a world'stokens.tomlpayload via the map shape rather than substring matching on serialized TOML. - Capability-based auth model is non-negotiable. The server never learns identity — only labels.
- Markdown-only scope is non-negotiable.
Decisions (resolved during planning)
- Health probes use
exec, not HTTP. Both charts ship liveness/readinessexecprobes that invoke thedemarkusCLI fetching/.well-known/agent-manifest.md(always public per/architecture.md). No core change. Same pattern asredis-cli ping/pg_isready. - UDP port is a values knob. Default
6309, override viaserver.udpPort. Documented option: switch to443for VPN/middlebox-hostile networks (Cloudflare Warp Zero Trust, corp firewalls that filter non-standard UDP). Protocol default stays6309. - Wildcard TLS via cert-manager DNS-01. Single
*.<root>cert covers all worlds + broker hostnames. HTTP-01 cannot work for worlds (no HTTP); DNS-01 is the standard path for QUIC services. - DNS topology. One A record per world + one for the broker, all under the same wildcard zone. Each world exposed by
Service: LoadBalancerprotocol: UDP; broker by standard Ingress (HTTPS). - Hub aggregator is the existing
demarkus-agentatclient/cmd/demarkus-agent/. Already implementscrawl+daemonsubcommands with TOML config, multi-worker crawl, per-host token auth, per-server + aggregated index publishing. Verified end-to-end on 2026-05-11. Phase 5 is no longer a blocking prerequisite. demarkus-agentlives inclient/cmd/, nottools/. It's a protocol client (usesfedcrawl,fetch,tokens,links), not a utility. Memory rule sharpened:client/cmd/= protocol clients (CLI, TUI, MCP, agent);tools/= utilities (broker, future sync/ops binaries).- Token-mint library lives at
protocol/token/, nottools/internal/token/(revised mid-Slice-A after CodeRabbit review).protocol/is reachable by bothserver/(wheredemarkus-tokenCLI lives) andtools/(where the broker will live), whichtools/internal/was not.protocol/already owns theHashTokenbyte-shape contract, so it's the natural home for the on-disk token primitives that must round-trip identically across CLI mints and broker mints. demarkus-tokenCLI stays atserver/cmd/demarkus-token/for now (rewired to importprotocol/token). Moving the binary totools/was deferred to a follow-up that splits the release pipeline (server release currently ships demarkus-token; tools has no release cadence yet). Same applies todemarkus-publish— still atserver/cmd/demarkus-publish/, deferred because hoisting it requires also hoistingserver/internal/store/out of internal-package scope.- Multi-OIDC. Broker speaks generic OIDC, not Google-specific. Provider behind a
Verifierinterface. Google validated first; Okta, Entra ID, Auth0 follow with config only. - Workload Identity (GKE) as a broker option. Values flag annotates the broker ServiceAccount with the GSA mapping. Off by default; on for GKE customers wanting no static SA keys. Not applicable to world servers.
- Broker is HA. Multi-replica with
resourceVersionoptimistic concurrency on Secret writes, retry on conflict. - Observability is log-derived, backend-agnostic. demarkus already emits structured slog. Charts ship Datadog autodiscovery annotations + reference configs for OTel Collector, Vector, Fluent Bit, Grafana Alloy. Customer's SRE picks the agent/backend. Latency histograms deferred — depends on what current slog includes per request; revisit during trials if needed.
- Backup/DR is documented, not built. The chart deliberately does not run backup CronJobs. Operations doc covers Velero, VolumeSnapshot, and
demarkus-agent syncas DR options. - Image hosting:
ghcr.io/latebit-io/{demarkus-server,demarkus-broker,demarkus-agent}. - Chart registry: OCI charts in GHCR.
- Cosign signing: deferred to backlog. Half-day CI add when wanted.
- Groups-claim case sensitivity (revised mid-Slice-C.1 after CodeRabbit back-and-forth). Group names match case-insensitively:
AllowConfig.Groupsis lowercased+trimmed at config load (same asDomainsandEmails) andgroupsMatchlowercases the claim's groups before compare. The validated IdP set (Google, Okta, Entra ID, Auth0) enforces case-insensitive group-name uniqueness, so distinct case-variant groups can't exist; a case-sensitive compare would silently fail when an operator writes "Engineering" but the IdP emits "engineering" after upstream normalization. Keycloak with deliberately distinct case-variant groups is out of scope; revisit if a customer asks. Domains and emails get the same lowercase-at-load + plain==treatment for symmetry. - Sweeper interval capped at 24h (Slice C.2). The
defaultToken.expiresAftertypical is 24h; a sweep interval longer than that would let expired tokens linger past their ExpiresAt for up to a whole sweep cycle, defeating the short-lived-tokens identity-lifecycle model (§Revocation §3).validate()rejects withsweeper.interval must be <= 24h. Lease-election timings (15s lease / 10s renew / 2s retry) intentionally not exposed in YAML — the client-go defaults match our failover budget and adding knobs invites misconfiguration. Revisit if a customer needs sub-15s failover. sweeper.disabledrather thansweeper.enabled(Slice C.2). YAML zero-value (false) is the production-safe default so an operator who omits the block gets the sweeper running. Naming the field positively (Enabled) would have made omission silently disable the janitor — exactly the wrong default for multi-replica deployments where expired tokens accumulate forever without it.- Rotate is re-login semantics, not refresh (Slice C.3).
POST /tokens/:label/rotatere-runsworldAllows(world.Allow, claims)on every call, so a user removed from the world's allowlist between mint and rotate can NOT extend access via rotation. The bearer ID-token verification at the HTTP layer already proves the IdP identity is still active; the per-world predicate re-check catches in-org permission changes (group removed, domain renamed, email taken off carve-out). Picked over rotation-as-refresh because rotation extends the lifetime — without re-auth, a user whose IdP account stayed live could rotate forever even after losing world-scope authorization. - Rotate scope vs. lifetime asymmetry (Slice C.3). Scope (paths + operations) stays frozen to the issuance record on rotate: operator narrowing of
DefaultToken.Pathsbetween mint and rotate does NOT shrink the rotated token's reach, because the user shouldn't be surprised by reduced access mid-session — they re-login to pick up new scope. Lifetime resets tonow + DefaultToken.ExpiresAfterfrom the operator's CURRENT config: operator tightening of expiry DOES apply on rotate, because shorter lifetimes are an explicit security-tightening lever and rotation must not bypass it. The asymmetry is intentional and pinned byTestRotateLabelPreservesIssuanceScope+TestRotateLabelLifetimeUpdatesToCurrentConfig. - Rotate sequence: mint new, then revoke old (Slice C.3). On revoke failure the new token is returned anyway with a wrapped soft error; the sweeper retires the orphan old label on expiry (or via drift if an operator hand-cleans). Picked over revoke-then-mint because that alternative leaves the user with no token if mint then fails, forcing a full re-login — worse UX than a brief two-valid-tokens window for the same user. Mirrors the Slice B partial-mint convention:
(MintResult{Label:""}, err)for hard failures,(MintResult{non-empty}, err)for soft partial success.
Open Questions
- First customer trial. Nesto (
*.library.nesto.ca) is path-B — trial waits for product. Trial runbook lands at/trials/nesto.mdwhen scoping starts. - CLI relocation to
tools/. Follow-up to Slice A. Requirestools/.goreleaser.yml, arelease-toolsjob in the workflow, aninstall.shblock fetching the tools archive, and updates to plugin scripts + helm image build. Acceptable to defer until the release-pipeline work has its own dedicated PR.
Repository Layout
Reflects state as of Slice C.3 merge (2026-05-12):
deploy/
helm/
demarkus-server/ # one-world chart (Phase 6.1) — shipped PR #107
demarkus-broker/ # OIDC token broker chart (Phase 6.3)
demarkus-agent/ # crawl/index agent chart (Phase 6.0) — shipped PR #106
k8s/
examples/
applicationset.yaml # Argo CD ApplicationSet over a worlds: list
kustomize-overlay/ # Kustomize alternative
observability/
datadog/ # autodiscovery annotations + dashboard JSON
otel-collector/ # collector config recipes
vector/ # vector config recipes
fluent-bit/ # fluent-bit parser + filter recipes
scripts/ # operator helpers (cert pre-check, MTU probe, etc.)
protocol/
auth.go # HashToken — sha256-<hex> contract (Slice A)
token/ # Generate, ReadFile, AppendEntry, WriteFile,
# FormatEntry, flock helpers (Slice A) +
# AppendBytes, RemoveBytes in-memory helpers
# (Slice B) + ParseBytes read-side helper
# (Slice C.2) for the broker's drift sweep.
# Shared by CLI and broker; on-disk path is
# atomic temp+rename, fsync data + parent dir
# for durability, advisory flock(2) for
# cross-process serialization, quoted-key
# handling for non-bare TOML labels.
server/cmd/
demarkus-server/ # the server binary (existing)
demarkus-token/ # token mint CLI — uses protocol/token
# (Slice A: rewired, not moved; tools/
# relocation deferred — see Open Questions)
demarkus-publish/ # publish CLI (existing; deferred move)
client/cmd/
demarkus-agent/ # protocol client — federation crawler (existing)
tools/
demarkus-broker/ # broker binary (Slice B PR #109, Slice C.1
# PR #110, Slice C.2 PR #111, Slice C.3
# PR #112) — imports protocol/token for
# byte-identical hashes (Phase 6.2).
# main.go + internal/broker/
# { config, session, oidc, issuer, server,
# labels, sweeper }. Slice B shipped the
# single-world OIDC mint flow; Slice C.1
# added groups-claim authorization,
# AllowEmails carve-out, and email
# canonicalization in Mint; Slice C.2 added
# the expiry+drift sweeper with
# coordination.k8s.io/Lease leader election
# (singleton across replicas); Slice C.3
# added POST /tokens/:label/rotate with
# re-login semantics + scope-frozen,
# lifetime-reset asymmetry. Remaining
# Slice C work: C.4 rate limit. SCIM
# webhook stays in backlog.
Sub-Phases
6.0 — demarkus-agent verified + chart ✓ binary, ✓ chart (merged PR #106)
Binary status (2026-05-11): existing demarkus-agent verified end-to-end against a 3-server smoke test (2 team worlds + 1 hub). Crawls, builds aggregated and per-server indexes, publishes to hub. Two bugs fixed during verification:
fedcrawl/crawl.go publishIndexaccepted onlyokstatus; first publish returnscreated, surfaced as a misleading warning despite the publish succeeding. Now accepts both.publishIndexalways usedexpected_version=0(create-only), causing every re-publish in daemon mode to fail withconflict. Now uses-1(no check) for idempotent hub re-publish; server's no-op-on-duplicate-content prevents version churn.Makefiledid not builddemarkus-agent; the agent had to be hand-built. Now builds withmake client.
Tests in client/internal/fedcrawl/crawl_test.go cover all three: create + re-publish + status acceptance + per-server / aggregated modes. bash pre-commit.sh clean.
Agent Helm chart shipped in PR #106 at deploy/helm/demarkus-agent/:
Deployment(not StatefulSet — agent is stateless modulo state file; recoverable from next crawl).- ConfigMap holding the TOML agent config (
seeds,hubs,crawl,politeness,schedule). - Secret holding per-host tokens for publishing to hub(s).
- ServiceAccount, no special RBAC needed (no k8s API calls).
- Exec liveness probe (
demarkus-agent versionreturns 0). - No
Service(agent is outbound-only). - Pod annotations for Datadog autodiscovery; log fields documented for
mint/crawlevents.
6.1 — demarkus-server Helm chart ✓ (merged PR #107)
deploy/helm/demarkus-server/. Production-grade.
Workload:
StatefulSet, 1 replica (multi-replica is Phase 7).volumeClaimTemplates— each world owns its PVC. Never a shared PVC.- Container image bundles
demarkus-server+demarkusCLI (for exec probes). - Exec liveness + readiness probes against
/.well-known/agent-manifest.md. - Resource requests/limits with sane defaults, overridable.
ServicetypeLoadBalancer,protocol: UDP, port fromserver.udpPort(default 6309). Annotations for cloud-specific LB type (NLB on AWS, etc.).
Secrets (world namespace):
<release>-tokens— TOML of SHA-256 hashes. Server-mounted.- Persistence across
helm upgradeis non-negotiable. Rendered withhelm.sh/resource-policy: keep+helm.sh/hook: pre-installand emptydata: {}in the template body. Helm creates the Secret on first install and never overwrites it on subsequent upgrades; broker writes via k8s API are preserved. - Bootstrap Job (below) seeds the initial
admintoken on first install only. The broker manages all subsequent entries at runtime via the k8s API. - Without this pattern, every
helm upgradewould silently wipe every broker-minted token. This is the single most important Helm correctness property in the chart and must be unit-tested. - kubelet propagation: Secret data changes propagate to the mounted file with a delay (default ~60s). Server re-reads via SIGHUP (preferred) or fsnotify; verify which during chart implementation.
- Persistence across
- Broker state (raw tokens are never persisted; email→label mappings and issuance metadata) lives in the broker namespace, not the world namespace. See §6.2 for the broker's
issuancesSecret. The world'stokensSecret holds only hashes and is the sole source of truth the server reads.
Persistence boundary recap: pod restarts, node failures, control-plane restarts → fine. helm upgrade → fine iff resource-policy: keep. kubectl delete namespace → everything gone (operations doc covers Velero / VolumeSnapshot recovery). helm uninstall without keep annotation → tokens Secret gone; with keep, retained.
Auth + TLS:
- TLS Secret mounted via
volumeMounts; cert/key paths via flags. - Optional
cert-managerCertificateresource (behind a flag) requesting*.<root>from a configuredClusterIssuer.
Bootstrap Job:
- Mints initial
admintoken on first install viaprotocol/token.Generate+protocol/token.AppendEntry(Slice A). helm.sh/hook: pre-installonly — does not run on upgrade.- Idempotent guard: reads the tokens Secret; skips if
adminlabel already exists. - SIGHUPs pod after writing (no-op on first install before pod is up).
Observability:
- Pod annotations for Datadog autodiscovery.
- Reference configs for OTel Collector, Vector, Fluent Bit in
deploy/observability/. - slog output already structured; no chart-side instrumentation needed.
RBAC:
- Bootstrap Job SA with
get/updateon the named Secret,get/list/createonpods/execfor SIGHUP. Namespace-scopedRole, notClusterRole.
Tests:
helm-unittestfor templates, including a test that assertsresource-policy: keepis present on the tokens Secret and that the rendered Secret has emptydata: {}.- Kind-based integration test in CI: install chart → exec into pod → verify health → publish via CLI → verify version increments →
helm upgradewith a changed value → re-verify token-based auth still works (regression guard against the upgrade-wipe footgun).
6.2 — demarkus-broker binary (tools/demarkus-broker/)
Slice B (single-world OIDC mint flow) merged as PR #109 (commit 2a6aae6, 2026-05-11). Slice C.1 (groups-claim authorization + AllowEmails carve-out + email canonicalization) merged as PR #110 (commit 343f808, 2026-05-12). Slice C.2 (expiry + drift sweeper with Lease-based leader election, RBAC-denied mint guard) merged as PR #111 (commit cb5f800, 2026-05-12). Slice C.3 (POST /tokens/:label/rotate with re-login semantics + scope-frozen lifetime-reset asymmetry) merged as PR #112 (commit 6ee0d8b, 2026-05-12). Remaining Slice C work — C.4 rate limit — is open; see §Sequencing for the ordering and §Status for the agreed sub-slicing.
Role & topology
- Issuance authority, not a request proxy. Broker sits on the
demarkus loginpath; clients then talk to world servers directly carrying the raw token. World servers stay identity-blind; broker never seesmark://requests. Capability model preserved. - One broker per universe (cluster) by default. Single OIDC client registration, single
worlds:list in values, single issuance state Secret. Multiple brokers only when (a) multiple OIDC providers must coexist, (b) hard tenant isolation between orgs sharing a cluster, or (c) Phase-7 geo split. N>1 is supported but not the common case. - Broker SA holds a namespace-scoped
Role+RoleBindingin each world's namespace, withget/patchlimited to that world'stokensSecret only. NoClusterRole. Blast radius bounded to the token Secrets it's explicitly granted. Slice C.2 sweeper also needscoordination.k8s.io/leasesget/create/updatein the broker namespace for leader election; chart RBAC (6.3) bundles both into the broker SA.
State
Two distinct Kubernetes Secrets, never merged:
| Secret | Lives in | Contents | Reader |
|---|---|---|---|
<world>-tokens |
each world's namespace | TOML: [tokens.<label>] hash=, paths=, operations=, expires= |
demarkus-server |
<broker>-issuances |
broker namespace | JSON: label → {email, world, paths, operations, issued_at, expires} + secondary index email → [labels] |
broker only |
World servers see only hashes. Email and identity live in broker state alone. Both Secrets follow the §6.1 resource-policy: keep pattern — broker state must also survive helm upgrade of the broker chart.
Plus one cluster-scoped coordination object (Slice C.2):
| Resource | Lives in | Contents | Reader |
|---|---|---|---|
demarkus-broker-sweeper Lease |
broker namespace | holderIdentity, renewTime, leaseDurationSeconds |
leader-election library only |
The Lease is recreated on demand; no migration concern. ReleaseOnCancel=true so rolling restarts hand it off in milliseconds.
Labels
Opaque IDs (usr_<random8>; 4 bytes of entropy, collision-retry up to 5 in the issuer). Not email-derived. Reasons:
- Sanitizing emails (dots, plus signs, IDN) into TOML keys is fragile — though note
protocol/token.FormatEntrydoes handle quoted-key emission for non-bare labels if you ever need it. - Token rotation yields a new label each time; opaque IDs reflect that naturally.
- "Revoke everything for
fredrick@x" becomes a broker-state index lookup, not a label string scan.
Per-world authorization (values schema, as shipped in C.1)
worlds:
- name: team-a
namespace: team-a
tokensSecret: team-a-tokens
allow:
domains: ["nesto.ca"]
groups: ["engineering"] # OIDC `groups` claim — Slice C.1
emails: ["alice@example.com"] # per-user carve-out — Slice C.1
defaultToken:
paths: ["/team-a/*"]
operations: ["read", "publish"]
expiresAfter: 24h
sweeper:
disabled: false # default: sweeper runs
interval: 5m # default: 5 min; capped at 24h
leaseName: demarkus-broker-sweeper # default
WorldConfig.Allow is a nested struct (the prior AllowDomains flat field was promoted into Allow.Domains during C.1 — Slice B was 2 days old with no deployed configs, so the breaking schema change was free). All three lists are lowercased+trimmed at config load with empty entries rejected at validation. Match is case-insensitive everywhere.
SweeperConfig (Slice C.2) defaults to running with a 5-min cadence; omitting the block gives production-correct behavior. See §Decisions for why the field is named disabled and why interval is capped at 24h.
On demarkus login, broker evaluates the OIDC identity against every world in its config and mints one token per world the user qualifies for. Client may request narrower scope; never broader.
The authorization predicate (worldAllows in issuer.go):
- All three lists empty → match (back-compat for worlds with no allowlist configured, same as the pre-Slice-C behavior).
- Email in
Allow.Emails→ match (per-user carve-out bypasses both domain and group requirements; the documented escape hatch when an IdP doesn't surface usable groups in the ID token). - Only
Emailswas configured and step 2 didn't fire → reject. Without this guard,allow.emails: [alice@x]with no other allowlist would silently mean "everyone plus alice" because the AND of empty Domains + empty Groups evaluates to true. Detected aslen(Domains)+len(Groups) == 0when we reach this branch. domainMatches AND groupsMatch, where an empty list in either dimension is a no-op for that dimension only. This is the standard "domain restriction intersected with groups restriction" — RBAC inside an org, not across orgs.
Mint also canonicalizes claims.Email (trim + lowercase) once at the top before authorization runs, so the persisted issuance owner is canonical and untrimmed-IdP-emit casing doesn't silently fail domainMatches (which previously kept trailing whitespace in the domain slice via LastIndex("@")). RotateLabel (Slice C.3) does the same canonicalization at its top before owner check and re-auth.
Token-mint library
Broker imports github.com/latebit/demarkus/protocol/token (Slice A + Slice B + Slice C.2 helpers). Available primitives:
Slice A — on-disk and pure helpers (used by demarkus-token CLI):
token.Generate(label, paths, operations) (Minted, error)— pure, no I/O. ReturnsMinted{Label, Raw, Entry}withRawas the one-time secret to hand to the user via the OIDC callback response, andEntryas the hash+capabilities to persist.token.AppendEntry(path, label, *Entry)— adds an entry to atokens.tomlfile with atomic temp+rename publishing,fsyncdata + parent dir for crash durability, advisoryflock(2)for cross-process serialization, and rejects duplicate labels viaErrLabelExists.token.WriteFile(path, File)— full-replace with the same atomicity/durability/locking guarantees (used for revoke from CLI).token.ReadFile(path)— decodetokens.tomlfrom disk.token.FormatEntry(label, *Entry)— render a single labeled entry; handles bare vs quoted TOML keys correctly.
Slice B — in-memory helpers (used by the broker, which round-trips TOML through k8s Secrets instead of files):
token.AppendBytes(existing []byte, label string, entry *Entry) ([]byte, error)— decodeexisting, append the new labeled entry, re-encode. Same duplicate-check (ErrLabelExists) and quoted-key handling asAppendEntry, minus the disk-side concerns (no temp+rename, no fsync, no flock — k8s API +resourceVersionprovide the equivalent ordering guarantees at a different layer).token.RemoveBytes(existing []byte, label string) ([]byte, error)— decode, drop the named label, re-encode. Used forDELETE /tokens/:labeland the expiry sweeper.
Slice C.2 — in-memory read-side helper (used by the broker's drift sweep):
token.ParseBytes(existing []byte) (File, error)— decodetokens.tomlbytes into the sameFileshapeReadFilereturns. Empty input gives an empty File with a non-nilTokensmap (matchesReadFile's "missing file = empty" convention) so membership checks don't need to special-case nil. Used bySweeper.readWorldLabelsto build a label set and compare against broker-side issuances — quoted-key labels survive the round trip, which substring search on the serialized bytes wouldn't.
Broker writes the returned []byte into the world's tokens Secret via Get + Update (read-modify-write with resourceVersion optimistic concurrency, retry-on-conflict up to 5). protocol.HashToken guarantees byte-identical hashes to what world servers read regardless of mint origin.
Slice C.3 — mintForWorld refactor (no new protocol/token helper):
Issuer.mintForWorld signature grew explicit paths, operations []string parameters so the same mint loop serves both Mint (passing the world's DefaultToken scope) and RotateLabel (passing the issuance-recorded scope). One code path, two call sites, no scope-coupling to the world config at the mint level. The cross-world-collision retry and rollback machinery (label collision → re-roll, appendIssuance failure → undo the world-Secret write within a 5s WithoutCancel-detached context) is inherited automatically by rotation.
Revocation
Three triggers, one cleanup index (the issuances Secret):
-
User-initiated (
demarkus token revoke <label>orrotate):DELETE /tokens/:labelcarrying OIDC ID token (bearer-authed; no broker session cookie — see "Routes" below).- Broker confirms
entry.email == claims.email(owner check — users can only revoke their own). - Calls
revokeIssuance(Slice C.2 helper extracted from the originalRevoke): patch the world'stokensSecret viaRemoveBytes, then drop the entry from the issuances Secret. The sweeper uses the same helper, so the two-Secret mutation logic lives in one place. POST /tokens/:label/rotate(Slice C.3): same bearer auth, owner check, plus a re-evaluation ofworldAllows(world.Allow, claims)before minting (see §Decisions "Rotate is re-login semantics"). On success the broker mints a new label with the issuance's recorded scope (paths/operations stay frozen across rotation), the world's currentDefaultToken.ExpiresAfter(lifetime resets), thenrevokeIssuanceretires the old label. On revoke failure the new token is still returned with a wrapped soft error; the sweeper retires the orphan old label on expiry.
-
Expiry sweeper (Slice C.2, shipped — broker in-process ticker, default every 5 min, leader-elected via
Leaseso only one replica sweeps):- Reads the issuances Secret once per tick.
- Splits entries into expired (
now.After(iss.ExpiresAt)) and not-expired; not-expired entries grouped by world. - For each world with non-expired entries: read that world's tokens Secret once via
ParseBytes, identify drifted entries (issuance record exists, label gone from world TOML — operator hand-edit, or a priorRevoke/Rotatethat failed midway between the two-Secret writes). - Calls
revokeIssuanceon each (expired ∪ drifted) entry. Per-entry failures log and continue — one stuck label doesn't starve the rest.
-
Identity lifecycle (user leaves the org):
- Default: short-lived tokens.
defaultToken.expiresAfterof 24h means stale tokens age out within a day. Nextdemarkus login(or rotate, since C.3) reruns OIDC +worldAllows; if the user's IdP account is disabled OR they've been removed from the world's allowlist, login/rotate fails. No broker→IdP coupling needed. Same approach as AWS STS /gcloud auth login. - Backlog: SCIM lifecycle webhook (
POST /scim/v2/Users/:id) for enterprise IdPs that push deprovisioning events (Okta, Entra). Optional add when a customer asks. - Periodic IdP re-validation rejected — worse trade-off than either above (couples broker to IdP rate limits, slower than SCIM, less simple than short-lived).
- Default: short-lived tokens.
SIGHUP intentionally not implemented in C.2/C.3 — revocation latency between the world Secret patch and the server stopping to accept the token equals kubelet Secret-mount propagation + whatever the server does on re-mount. Documented in §Risks; revisited in 6.3 chart work where we'll either prove the server already re-reads on mount-time mtime changes or add a SIGHUP path with pods/exec RBAC.
Cleanup edge cases
- Orphan in
tokens.toml(admin-minted via legacydemarkus-tokenCLI): broker never claims it. Admins manage via CLI. Broker API only operates on labels it minted. - Orphan in issuances Secret (admin hand-deleted from world tokens Secret): Slice C.2 sweeper detects drift on each pass via
ParseBytesof the world Secret and a label-set membership check; the orphan is dropped from the issuances Secret. Verified byTestSweepPrunesDrift. - Issuance for an unconfigured world (operator removed the world between mint/rotate and the sweep): same conservative posture as
Revoke/Rotate— log and skip, never silently drop. The issuance record stays so the operator can investigate (e.g. re-add the world to clean up properly). Verified byTestSweepSkipsUnconfiguredWorldandTestRotateLabelMissingWorldErrors. - In-flight request after revoke/rotate: a raw token already authenticated on a live QUIC connection completes its current request. Property of the capability + connection-reuse model. Documented; not fixable without core server changes. Rotate also leaves a brief window where BOTH old and new tokens are valid (sequence is mint-then-revoke); this is intentional per §Decisions and the user-visible cost is bounded by the sweeper's expiry-based cleanup if revoke fails.
- Partial mint across multiple worlds: if a world's Secret patch fails mid-iteration (e.g. RBAC denial), successful mints from prior worlds stay live and the caller gets back the partial result plus the error. The failing world has no orphan in either Secret —
mintForWorldonly commits to the issuances Secret after the world Secret write succeeds, and the rollback onappendIssuancefailure removes the world-Secret entry. Verified byTestMintRBACDeniedNoPartialState(Slice C.2). - Soft-partial rotate (Slice C.3, new edge case): if the mint of the new token succeeds but the revoke of the old fails, both labels coexist in both Secrets until the sweeper retires the old one on expiry. The user gets a successful 200 with the new token (mint succeeded — what the user asked for completed); the operator sees a WARN log line
broker: rotate partial — old label not revoked. Verified byTestRotateTokenSoftPartial.
Routes (HTTP, behind Ingress)
GET /auth/login— OIDC redirect entry point. Sets a signed state cookie (HMAC-SHA256 over JSON{nonce, expiresAt}, HttpOnly+Secure+SameSite=Lax, path-scoped to/auth/callback, default 5-minute TTL).GET /auth/callback— OIDC callback. Verifies state cookie (CSRF), exchanges code, mints tokens for every qualifying world, returns JSON{world → raw_token}once. No broker session cookie is issued — the state cookie is the only cookie in the system, and it exists purely to defend the OIDC dance against CSRF.GET /tokens— list caller's tokens from broker state (labels + metadata only, never raw tokens). Bearer-authed with the user's OIDC ID token, not a broker session.DELETE /tokens/:label— revoke caller-owned token. Bearer-authed (OIDC ID token).POST /tokens/:label/rotate— Slice C.3, shipped. Bearer-authed. Owner check +worldAllowsre-validation against current Allow config (re-login semantics); on success returns the new MintResult with the issuance's recorded scope and a fresh expiry. Soft partial (mint succeeded, old revoke failed) returns 200 with a WARN log; the orphan old label retires on the next sweep cycle.GET /healthz,GET /readyz.
Rationale for skipping a broker session: the CLI is the primary /tokens consumer and already handles token lifetimes; the browser only ever sees the one-time JSON callback response. Adding a session cookie would couple broker state to browser state for no gain. The Verifier interface accordingly exposes VerifyIDToken(ctx, raw) (Claims, error) so handlers can authenticate bearer tokens uniformly.
OIDC providers
Provider behind a Verifier interface (AuthCodeURL, Exchange, VerifyIDToken). Initial implementation wraps coreos/go-oidc/v3 + golang.org/x/oauth2. Follow-ons: Okta, Entra ID, Auth0 — config only. Group-claim availability varies per provider; doc page per provider explains required claim configuration (e.g., Entra needs the groups optional claim enabled; some providers require a userinfo call to fetch groups not in the ID token).
Groups claim sourcing (Slice C.1 scope): ID-token only. The Claims struct's Groups []string field is populated from the groups claim in the verified ID token. Providers that surface groups in userinfo instead of the ID token (Google, some Entra configs) need a richer Verifier impl that runs a userinfo call after Verify. Out of Slice C scope; revisit when a customer needs it. The AllowEmails carve-out is the documented workaround in the meantime.
HA
Multi-replica. Issuance writes to k8s Secrets use resourceVersion optimistic concurrency with retry-on-conflict. Sweeper (shipped in C.2) uses a coordination.k8s.io/Lease for leader election via k8s.io/client-go/tools/leaderelection, so only one replica runs the periodic loop at any moment. ReleaseOnCancel=true on graceful shutdown so rollouts hand off the lease in milliseconds rather than waiting out the 15s expiry. Mint/List/Revoke/Rotate handlers remain on every replica — those are request-driven, not periodic, and don't need coordination beyond the per-Secret optimistic-concurrency retry inside mutateSecret.
Slice B test surface (shipped)
- Mint flow: table-driven,
Verifiertest double inoidc_test.go, fake k8s clientset, OIDC discovery mocked viahttptest. End-to-end coverage at the HTTP layer viahttptest.NewServer. - State cookie: HMAC verify path, expiry path, malformed-envelope path, short-key rejection.
- Config: YAML decode with
KnownFields(true)rejecting typos; zero-expiresAfterrejected at startup. - Issuer:
resourceVersionretry-on-conflict, label collision retry, mint ordering (world Secret first, issuance record second — orphan-in-issuances is the documented partial-failure mode). - Server: clock decoupling between server and issuer (state-cookie expiry vs token-expiry assertions need different clocks).
87% line coverage on internal/broker/. bash pre-commit.sh clean.
Slice C.1 test surface (shipped — PR #110)
- 15-row table
TestMintAuthorizationPredicatecovers every (Domains × Groups × Emails) × accept/reject cell, including the(domain AND groups) OR email-carve-outpredicate, the only-emails-set reject case, and case-insensitive group matching (claim-side). TestMintCanonicalizesEmail: passes" Alice@Example.COM\t"and asserts both domain match succeeded and the persisted issuance email isalice@example.com. Regression guard against the IdP-side whitespace/case footgun that motivatedMint's top-of-function trim+lowercase.TestLoadConfigrows for each allowlist dimension: lowercase normalization at load (domains, emails, groups) and empty-entry rejection (allow.<dim>[N] is empty). Pins the load-side half of the case-insensitive contract documented above.TestDeleteTokenNotOwner(existed since Slice B PR #109): different OIDC subject than the issuance email → 403. Already covered the owner-check 403 path that the original Slice C surface listed as "open"; flagged during C.1 review.
Slice C.2 test surface (shipped — PR #111)
TestSweepRetiresExpiredKeepsFresh: pinned-clock sweeper against two issuances (one pastExpiresAt, one fresh). After onesweep(ctx)pass, the expired entry is removed from both Secrets and the fresh one is intact in both. Exercises the primary expiry path.TestSweepPrunesDrift: two issuance records against one world; only one has a matching entry in the world's tokens Secret. After one pass, the drifted issuance record is dropped, the present one is left alone. VerifiesParseBytes-backed drift detection.TestSweepSkipsUnconfiguredWorld: issuance points at a world the broker is no longer configured for; sweeper logs and skips rather than silently dropping. The issuance stays so an operator can investigate. Matches the conservative posture fromRevoke.TestSweepEmptyIssuancesIsNoop: empty initial state; no panic, no spurious Secret creation.TestSweeperLeaderElection: twoRunLeaderElectedagainst a shared fake clientset. Exactly one is elected, runssweep; the follower stays at zero sweep count. Cancel the leader's context;ReleaseOnCancelhands the Lease back; the follower takes over and starts sweeping. Both goroutines explicitly cancelled and joined before test return so the next test starts clean.TestMintRBACDeniedNoPartialState(inissuer_test.go): multi-world config; fake-clientset reactor returnsForbiddenonupdatefor team-b's tokens Secret. Asserts team-a fully committed (world Secret + issuance record), team-b has zero state in either Secret, and the error names the failing world. Validates the "no orphan on RBAC failure" property the plan §Cleanup edge cases promises.TestLoadConfigrows:sweeper.intervaldefaults to 5m when omitted; over 24h rejected with explicit error; negative rejected.
bash pre-commit.sh clean. Package coverage 81.0% (up from C.1's 78.9%).
Slice C.3 test surface (shipped — PR #112)
Issuer-layer (issuer_test.go):
TestRotateLabelHappyPath: mint viaMint, rotate, assert new label is non-empty + distinct, world Secret has only the new label, issuances has exactly one entry with the new label, persisted email is canonical.TestRotateLabelRejectsUnverifiedEmail: defense-in-depth gate (!claims.EmailVerified → ErrEmailUnverified) mirrored from Mint. Asserts original token state untouched on the reject path.TestRotateLabelNotOwner: alice mints, mallory rotates →ErrNotOwner, alice's state intact in both Secrets. Usesstrings.EqualFoldfor the owner compare to handle non-canonical legacy rows.TestRotateLabelUnknownLabel:ErrNotFoundfor missing label.TestRotateLabelPreservesIssuanceScope: operator narrowsDefaultToken.Pathsbetween mint and rotate; rotated token keeps the original wider scope. Pins the "scope sticky to issuance" half of the asymmetry.TestRotateLabelLifetimeUpdatesToCurrentConfig: operator tightensExpiresAfterfrom 24h to 1h; rotated token'sExpiresAtreflects the new 1h. Pins the "lifetime resets to operator-current" half.TestRotateLabelReauthorizesAgainstCurrentAllow: operator changesAllow.Domainsto exclude alice between mint and rotate; rotation rejected withErrNotAuthorized, original token intact. Pins the rotate-as-relogin contract.TestRotateLabelMissingWorldErrors: issuance points at a world removed from config; rotation surfaces the misconfiguration with an error naming the world. Same posture as Revoke's world-not-configured guard.
HTTP-layer (server_test.go):
TestRotateTokenSuccess: 200, well-formedMintResultJSON, new label.TestRotateTokenNotOwner: 403.TestRotateTokenNoLongerAuthorized: 403 with the dedicated "no longer authorized" log line.TestRotateTokenNotFound: 404.TestRotateTokenSoftPartial: reactor fails Update #2 on team-a (the old-label revoke) after Update #1 (new-label mint) passes; asserts 200 with new token, both old + new labels still live in both Secrets, two issuances pending sweep. Validates the partial-success branch the handler'scase minted.Label != ""path implements.TestRotateTokenUnauthenticated: 401 (gate via sharedauthenticatehelper, doesn't reachRotateLabel).
Package coverage 81.1%. bash pre-commit.sh clean.
Slice C remaining test surface (open)
- C.4: per-subject rate limit smoke test on
/tokens(assert N+1 returns 429); separate IP-keyed limit on/auth/login.
6.3 — demarkus-broker Helm chart
Multi-replica HA. RBAC bundles per-world Roles for get/patch on each world's tokens Secret plus a broker-namespace Role for get/create/update on coordination.k8s.io/leases (the sweeper's lease). sweeper: block exposed in values with sensible defaults so customers running single-replica dev installs can disable it cleanly.
6.4 — Universe topology examples
ApplicationSet + Kustomize overlay. As previously specified.
6.5 — Observability recipes
Per-backend configs in deploy/observability/. As previously specified. Slice C.2 added sweeper-side log lines (broker: swept, broker: sweep failed, broker: sweeper observing new leader, broker: sweeper lost leadership) that the recipes' parsers should route to the same audit-log fields as mint/revoke. Slice C.3 added rotate-side log lines (broker: rotate succeeded, broker: rotate owner mismatch, broker: rotate denied — caller no longer authorized for world, broker: rotate partial — old label not revoked) — same routing target.
6.6 — Documentation suite
/deployment/*.md + per-chart READMEs. As previously specified.
6.7 — Release pipeline
GHCR images + OCI charts. Cosign deferred to backlog. Also folds in the deferred CLI relocation (demarkus-token, demarkus-publish) from server/cmd/ to tools/ — that change requires a new tools/.goreleaser.yml + release-tools workflow job + install.sh block, all of which naturally belong in the release-pipeline slice.
Sequencing
- Slice A — token-mint library ✓ merged 2026-05-11 (PR #108).
- 6.0 chart ✓ merged 2026-05-11 (PR #106).
- 6.1 server chart ✓ merged 2026-05-11 (PR #107).
- 6.2 broker binary — Slice B ✓ merged 2026-05-11 (PR #109, commit
2a6aae6). Addsprotocol/token.AppendBytes/RemoveBytesin-memory helpers for k8s-Secret-backed callers. - 6.2 broker binary — Slice C, split into four landed PRs:
- C.1 ✓ merged 2026-05-12 (PR #110, commit
343f808) — groups-claim +AllowEmailsauthorization. NestedWorldConfig.Allowschema (breaking change vs Slice B'sAllowDomains); lowercase-at-load + plain==everywhere; case-insensitive group matching; email canonicalization inMint; configurable groups via ID-token claim. - C.2 ✓ merged 2026-05-12 (PR #111, commit
cb5f800) — expiry + drift sweeper withcoordination.k8s.io/Leaseleader election viak8s.io/client-go/tools/leaderelection. Singleton across replicas,ReleaseOnCancelfor fast handoff.protocol/token.ParseBytesadditive helper for drift detection.Issuer.revokeIssuanceextracted as shared helper between user-Revokeand sweeper.sweeper.intervalcapped at 24h;sweeper.disablednamed for safe zero-value default. RBAC-denied mint test pins "no partial state" guarantee. - C.3 ✓ merged 2026-05-12 (PR #112, commit
6ee0d8b) —POST /tokens/:label/rotatewith re-login semantics (re-runsworldAllowson every call) + scope-frozen/lifetime-reset asymmetry.Issuer.mintForWorldrefactored to take explicitpaths, operations []stringso Mint and RotateLabel share the cross-world-collision + rollback machinery. EmailVerified defense-in-depth gate and EqualFold owner check mirror Mint/Revoke respectively. Mint-then-revoke sequence with soft-partial return on revoke failure (new token returned, sweeper retires the orphan on expiry). - C.4 — per-subject rate limit middleware (
golang.org/x/time/rate, in-memory per-replica). Keyed byhashSubject(claims.Subject)for authed endpoints, source IP for/auth/login. - SCIM webhook stays in backlog (no customer ask yet).
- C.1 ✓ merged 2026-05-12 (PR #110, commit
- 6.3 broker chart — after 6.2 Slice C testable.
- 6.4 topology examples.
- 6.5 observability recipes.
- 6.6 docs — incremental throughout.
- 6.7 release pipeline — final; also relocates
demarkus-token/demarkus-publishtotools/.
Rough effort: ~1 week of focused work remaining (3 core code units + Slice C.1 + Slice C.2 + Slice C.3 shipped; what's left is C.4 in the broker, the broker chart, examples, recipes, docs, and the release pipeline).
Backlog (deferred, easy to add later)
- Cosign signing of images + chart releases. Half-day CI add when wanted.
- Latency log-enrichment (
duration_msfield on request slog lines). Tiny additive change. Defer until trials show it's needed. - SCIM lifecycle webhook on the broker (
POST /scim/v2/Users/:id) for enterprise IdPs that push deprovisioning events. Adds responsiveness beyond what short-lived tokens give. Optional add when a customer asks. - CLI relocation to
tools/(demarkus-token,demarkus-publish). Bundled with 6.7 release-pipeline work — see Open Questions. - Userinfo-based groups in the broker
Verifierfor providers (Google, some Entra configs) that don't surface groups in the ID token. Slice C.1 ships ID-token-only;AllowEmailsis the documented workaround. - Case-sensitive group matching for Keycloak (or any future case-sensitive IdP) where an operator deliberately maintains distinct case-variant groups for different access levels. Slice C.1 chose case-insensitive matching for the validated IdP set; revisit if a customer asks.
- Configurable lease timings (
sweeper.leaseDuration,renewDeadline,retryPeriod). C.2 hardcodes the client-go defaults (15s/10s/2s); expose only when a customer's failover budget needs different numbers. - SIGHUP on revoke + sweep + rotate to shrink the revoke-to-effect window from "kubelet Secret-mount propagation" to "near-zero." Needs
pods/execRBAC across world namespaces — non-trivial blast-radius expansion. Verify whether the server already re-reads on mount-time changes during 6.3 chart work; SIGHUP may not be needed at all. - Sweeper backing-store migration when the issuances Secret hits the ~1MB / ~5000-issuance k8s storage ceiling. Pagination of
sweep()doesn't help (Secrets aren't a paginated resource); the real fix is a different store (CRD with watch/list, ConfigMap-per-user, external KV). Phase 7+.
Risks
- Observability via logs ceiling. If customers want signals not derivable from current slog (latency, internal state like version counts or PVC fullness), we hit a wall. Mitigation: log enrichment is a small additive change; internal-state metrics derivable by a cluster-side sidecar that calls
LISTperiodically. - Broker secret-write blast radius. Holds k8s API creds across world namespaces. Mitigated by namespace-scoped
Roles (one per world), audit log, optionalNetworkPolicy. Per-worldRoleis non-negotiable — neverClusterRole. The Slice C.2 RBAC-denied mint test (TestMintRBACDeniedNoPartialState) validates clean failure when the SA's permission scope shrinks: failing world has zero state, prior worlds in the same Mint stay committed. - OIDC provider coupling. First impl is Google; structure so second provider is a one-day add.
- Token revocation in-flight latency. SIGHUP reloads
tokens.toml, but a request already authenticated on a live QUIC connection completes. Property of model. Slice C.2 sweeper and Slice C.3 rotate-revoke do not SIGHUP world servers; revocation latency = kubelet Secret propagation + whatever the server actually does on re-mount. Verify server reload behavior during chart implementation (§6.3); SIGHUP path is backlogged. - Rotate transient two-tokens window. Slice C.3's mint-then-revoke sequence means both old and new labels are briefly valid in the world Secret between the new mint landing and the old revoke completing. On revoke failure (soft-partial), the window widens until the next sweep cycle. Both tokens belong to the same identity so this is not a privilege issue, but it does mean
Listreturns two entries during the window and the operator audit log briefly shows two live issuances for one user. Documented in §Cleanup edge cases; verified byTestRotateTokenSoftPartial. - Helm upgrade wiping tokens. Classic templating footgun: rendering a Secret on every upgrade overwrites broker-minted content. Mitigated by
helm.sh/resource-policy: keep+pre-installhook on the world tokens Secret (and the broker issuances Secret). Regression-tested in CI via the kind integration test. - Issuances Secret scaling wall. The broker's issuances Secret has a ~1MB hard cap from etcd encoding, roughly 5000-7000 records at ~150 bytes apiece. Long before sweep memory becomes a problem, the Secret stops being writable — that's the real scaling ceiling for the current design. Pagination doesn't apply (Secrets aren't paginated). When we hit this, the Phase-7+ fix is a different backing store (CRD with watch+list, ConfigMap-per-user, external KV), not a streaming sweep. Documented in
sweep()'s doc comment. - Chart proliferation. Three charts + examples + dashboards. Mitigate with shared common-labels templates.
- Trial scope creep. First customer is path-B. If feedback pulls scope back to "demo slice" mid-build, decide explicitly.
Status
Plan v12, 2026-05-12. Slice A, 6.0 chart, 6.1 chart, 6.2 broker binary Slice B, 6.2 broker binary Slice C.1, 6.2 broker binary Slice C.2, 6.2 broker binary Slice C.3 all merged (PRs #106, #107, #108, #109, #110, #111, #112).
- Slice B (PR #109, commit
2a6aae6, 2026-05-11): single-world OIDC mint flow withVerifier-interface OIDC provider, signed state cookie (no broker session), bearer-auth on/tokensandDELETE /tokens/:label, k8s Secret writes viaresourceVersionretry, opaqueusr_<8 hex>labels with collision-retry, 87% line coverage. Slice B also addedprotocol/token.AppendBytes/RemoveBytesin-memory helpers — the library that started Slice A on disk now serves both disk callers (CLI) and Secret callers (broker) from the same byte-shape contract. - Slice C.1 (PR #110, commit
343f808, 2026-05-12): groups-claim +AllowEmailsauthorization. NestedWorldConfig.Allowschema replaces the flatAllowDomainsfield (breaking config change; Slice B was 2 days old with no deployed configs). Authorization predicate is(domain AND groups) OR email-carve-outwith an explicit reject for the only-emails-set case. Email canonicalized (trim+lowercase) once inMintto fix an untrimmed-email footgun indomainMatches. Groups, domains, and emails all lowercased+trimmed at config load with empty-entry rejection; group matching is case-insensitive because the validated IdP set (Google/Okta/Entra/Auth0) enforces case-insensitive group-name uniqueness. ID-token-sourced groups only; userinfo-based groups in backlog. - Slice C.2 (PR #111, commit
cb5f800, 2026-05-12): expiry + drift sweeper, leader-elected viacoordination.k8s.io/Leaseso only one replica sweeps at a time. Single-pass per tick: read issuances once, group non-expired entries by world, read each affected world's tokens Secret at most once via the newprotocol/token.ParseByteshelper, retire (expired ∪ drifted) entries viaIssuer.revokeIssuance(extracted fromRevokeso user-revoke and sweeper share the two-Secret mutation).Sweeper.RunLeaderElectedwrapsk8s.io/client-go/tools/leaderelection.RunOrDiewithReleaseOnCancel=truefor sub-second handoff on rolling restarts. Lease timings hardcoded to client-go defaults (15s/10s/2s); onlysweeper.disabled(default false),sweeper.interval(default 5m, capped 24h), andsweeper.leaseNameare exposed in YAML.main.gowires the sweeper alongside HTTP with explicit context cancellation + WaitGroup join on shutdown. RBAC-denied mint guard (TestMintRBACDeniedNoPartialState) pins the "no partial state on a failed world's mint" property. Coverage 81.0% (up from C.1's 78.9%). - Slice C.3 (PR #112, commit
6ee0d8b, 2026-05-12):POST /tokens/:label/rotatewithIssuer.RotateLabel(ctx, claims, label). Re-login semantics: every rotate re-runsworldAllows(world.Allow, claims), so a user removed from the world's allowlist between mint and rotate can NOT extend access. Scope-vs-lifetime asymmetry: paths + operations stay frozen to the issuance record (operator narrowing only applies on nextdemarkus login), but lifetime resets tonow + DefaultToken.ExpiresAfterfrom the operator's current config (operator tightening DOES apply on rotate). Sequence is mint-then-revoke; soft-partial return when mint succeeds but old-revoke fails (new token returned, sweeper retires orphan on expiry).Issuer.mintForWorldrefactored to take explicitpaths, operations []stringso Mint and RotateLabel share the cross-world-collision + rollback machinery. EmailVerified gate mirrored from Mint; owner check usesstrings.EqualFoldto match Revoke and handle non-canonical legacy rows. 13 new tests acrossissuer_test.goandserver_test.go. Coverage 81.1%.
Next: 6.2 Slice C remaining:
- C.4 — per-subject rate limit middleware (
golang.org/x/time/rate, in-memory per-replica). Defaults proposed: 10/min subject on/tokens+revoke+rotate (burst 5), 20/min IP on/auth/login(burst 5); both override-able via config.
Then 6.3 broker chart → examples/recipes/docs → 6.7 release pipeline.