Parsec
Parsec is a scalable realtime messaging engine built on Centrifuge (OSS). It ships as a Go library (primary), a CLI binary, and a Twirp JSON RPC service — three surfaces over the same engine.
This book is the reference manual. Start with the quickstart, or skip to the section that matches your role:
- Building against parsec — library overview → options → custom sinks
- Operating parsec — deployment → config file → observability
- Integrating from another language — Twirp RPC
- Curious about the model — architecture
Capability map
| Need | Read |
|---|---|
| Try parsec in 30 seconds | Running with Docker |
| Wire a browser to parsec | Browser client |
| Test code that embeds parsec | parsectest helpers |
| Open a channel, publish, subscribe | Quickstart |
| Understand the namespace grammar | Channel naming |
| Token-gate a channel with patterns | ACL scopes |
| Rotate signing keys without downtime | Key rotation |
| Accept IdP-issued bearers | OIDC bridge |
| Survive sink outages | DLQ |
| Gate abusive callers | Rate limiting |
| Cluster across machines | Deployment |
| Span regions | Multi-region |
| Speak HTTP/3 | WebTransport |
| Compress chatty channels | Delta compression |
| Embed the operator UI | Admin UI |
| Scrape metrics | Observability |
What Parsec is
- A broker process that holds long-lived client connections (WebSocket + optional WebTransport).
- A channel manager with a strict
public:/private:namespace convention and TTL-driven lifecycle. - A library you embed in Go services to publish, subscribe, and authenticate.
- A CLI and a Twirp RPC surface — two ways to control the same engine from outside.
What Parsec is not
- Not an everything-store. It does not own your business data; it ships events.
- Not a durable work queue. Use one for jobs; use parsec for “you have a notification”.
- Not a Centrifugo Pro replacement. Parsec uses the OSS library; we ship our own delta-compression toggle, admin UI, and presence primitives — only the subset we need.
Installation
How to get a working parsec binary on disk.
Prerequisites
- Go 1.26.1 or newer. Parsec uses generics, structured logging, and the
modern
slogAPI; older toolchains will not build. - A POSIX shell. The bootstrap message uses standard environment variables; nothing exotic.
- No CGO. The Makefile sets
CGO_ENABLED=0and treats any new C-toolchain dependency as a build failure. You do not need a C compiler.
Verify your toolchain:
go version
# go version go1.26.1 darwin/arm64
Install with go install
The fastest path. Drops the binary in $(go env GOBIN) (or
$(go env GOPATH)/bin if GOBIN is unset):
go install github.com/frankbardon/parsec/cmd/parsec@latest
Confirm:
parsec --version
# parsec version dev
The dev string is the default ldflags version stamp. Tagged releases
substitute the tag at build time.
Build from source
Clone the repo and use the Makefile. The resulting binary lands at
bin/parsec:
git clone https://github.com/frankbardon/parsec.git
cd parsec
make build
./bin/parsec --version
make build runs:
go build -trimpath -ldflags="-s -w" -o bin/parsec ./cmd/parsec
-trimpath strips local build paths from the binary;
-ldflags="-s -w" drops the DWARF debug info and symbol table to shrink
the artifact. CGO is disabled by the Makefile.
Run the test and lint targets
The same Makefile drives the rest of the development loop:
make test # go test ./...
make lint # go vet + staticcheck
make cover # coverage profile
make docs # mdbook build (requires mdbook on PATH)
make lint shells out to staticcheck via go run; it does not need a
separate install step.
What you should do next
- Walk through the quick start to boot the broker and publish your first message.
- Skim the channel naming convention — every surface enforces it, so it pays to internalize early.
- If you intend to embed Parsec, jump to the library overview.
Quick start
Boot the broker, open a public channel, publish, mint a private channel, and exchange a refresh token. Five minutes end-to-end.
1. Install
go install github.com/frankbardon/parsec/cmd/parsec@latest
Or build from source:
git clone https://github.com/frankbardon/parsec.git
cd parsec
make build # binary lands at bin/parsec
2. Boot the server
The simplest invocation listens on :8000 with a persistent keyring
under /var/lib/parsec. First boot mints a bootstrap mgmt token and
prints it to stderr — capture it.
parsec serve --addr :8000 --state-dir /var/lib/parsec
# parsec serve: bootstrap mgmt token (expires 2026-05-23 18:04:11 UTC):
# eyJhbGciOiJIUzI1NiIs...
--state-dir makes the HMAC keyring file-backed so tokens survive
restart. Omit it for fully ephemeral dev runs.
See serve for every flag.
3. Export your operator credentials
Most CLI commands hit the management RPC, so they need the bearer:
export PARSEC_TOKEN="eyJhbGciOiJIUzI1NiIs..."
export PARSEC_SERVER="http://localhost:8000"
Already use an IdP? Run parsec login oidc instead
of pasting a bearer.
4. Open a public channel
parsec channels open public:webapp.system.status
Public channels have no token gate — anyone can subscribe. They still
have a TTL (30 min inactivity by default; override with --ttl 1h).
5. Publish to it
echo '{"msg":"hello"}' | parsec publish public:webapp.system.status
The broker returns a publish offset and an epoch. Late subscribers can replay from any offset within the 5-minute history window.
6. Subscribe as a probe
parsec subscribe public:webapp.system.status
Server-Sent Events client for debugging. Publications appear as NDJSON
on stdout; Ctrl-C to exit. Production clients use the WebSocket
(/connection/websocket) or the optional
WebTransport endpoint.
7. Mint a private channel
Private channels need an access token. channels create returns the
access + refresh pair in one response:
parsec channels create private:webapp.user.42.downloads \
--subject user-42 --ttl 30m
Access tokens default to 5 minutes; refresh tokens live up to the
channel TTL (capped at 1 hour). The token bodies are JWTs you can pass
to centrifuge-js directly. See
private channels.
8. Exchange a refresh for a fresh access
parsec tokens refresh "<refresh-token>"
RefreshToken is public — the refresh token in the body authenticates
the call. The new access token cannot outlive the refresh expiry.
Next steps
- Production deployment — config file, deployment.
- Cluster across machines — point
Options.RedisClientat a shared Redis; the broker, registry, keyring, DLQ, and rate limiter all switch automatically. - Authenticate with patterns — ACL scopes
cover
*/**grants and deny-wins precedence. - Embed in a Go service — library overview.
- Operate — key rotation, DLQ, observability, rate limiting.
- Troubleshoot — error codes catalog.
Running with Docker
Parsec ships a multi-arch (linux/amd64, linux/arm64) container
image. The Dockerfile builds a fully static binary on
golang:1.26.1-alpine and ships it on gcr.io/distroless/static-debian12
— the result is ~7 MB total, no shell, no package manager, no CVE
surface beyond the binary itself.
Tagged releases publish to ghcr.io/frankbardon/parsec:
| Tag | Tracks |
|---|---|
latest | Most recent tagged release |
vX.Y.Z | Specific semver release |
vX.Y | Latest patch of the minor |
vX | Latest minor of the major |
main | Tip of main branch (unstable) |
sha-<short> | Specific commit |
One-shot run
docker run --rm -p 8000:8000 ghcr.io/frankbardon/parsec:latest
# parsec serve: bootstrap mgmt token (expires 2026-05-23 18:04:11 UTC):
# eyJhbGciOiJIUzI1NiIs...
The bootstrap mgmt token is on stderr. Capture it from the container logs:
docker logs <container-id> 2>&1 | grep bootstrap
Persistent keyring (recommended):
docker run --rm -p 8000:8000 \
-v parsec-state:/var/lib/parsec \
ghcr.io/frankbardon/parsec:latest
The image’s default CMD is
serve --addr :8000 --state-dir /var/lib/parsec. Override with any
parsec subcommand:
docker run --rm ghcr.io/frankbardon/parsec:latest --version
docker run --rm ghcr.io/frankbardon/parsec:latest channels list \
--server http://parsec.internal:8000 \
--token "$PARSEC_TOKEN"
docker compose
The repo ships a docker-compose.yml that boots parsec + Redis
together. It mounts examples/config/parsec.docker.yaml into the
container so the broker, channel registry, keyring, DLQ, and rate
limiter all run against Redis — the same multi-node code paths
production uses.
git clone https://github.com/frankbardon/parsec.git
cd parsec
docker compose up --build
Once both services are healthy:
docker compose logs parsec | grep bootstrap
# capture the mgmt token, export PARSEC_TOKEN, then:
parsec channels list # (or use the in-container CLI)
Drop the state volume to reset auth:
docker compose down -v
Building locally
docker build -t parsec:dev .
docker run --rm -p 8000:8000 parsec:dev
The build is cached on go.mod / go.sum, so iteration on Go source
typically reuses the module-download layer.
To override the version stamp baked into the binary:
docker build --build-arg VERSION=v0.1.0 -t parsec:v0.1.0 .
Health checks
The image exposes a /healthz endpoint that returns 200 OK once the
HTTP listener is bound. Wire it into your orchestrator:
livenessProbe:
httpGet: { path: /healthz, port: 8000 }
initialDelaySeconds: 2
periodSeconds: 10
For Kubernetes deployments, also gate the bearer middleware by setting
PARSEC_METRICS_TOKEN and scraping /metrics from your monitoring
namespace.
Image surface
- User:
nonroot(uid 65532, gid 65532). Drops root from the container at start; required for restricted PSP/PSA environments. - Entrypoint:
/usr/local/bin/parsec. The image has no shell —docker exec -it ... shwill fail. Use a sidecar for debugging. - Ports:
8000/tcp(HTTP / WebSocket / Twirp). WebTransport (HTTP/3) uses UDP on a separate listener; expose it as needed. - Volumes:
/var/lib/parsec(keyring + any future state). - Labels: Standard OCI labels point at the source repo + license.
Security notes
- Always set
PARSEC_METRICS_TOKEN(env or YAML) when/metricsis reachable from outside the cluster. The default is unguarded so dev evaluation works out of the box. - Always set
--state-diron a persistent volume in production. An ephemeral keyring means every container restart issues a new bootstrap mgmt token and invalidates every outstanding access / refresh token. - The image is rebuilt on every tagged release. Pin the digest in
production (
ghcr.io/frankbardon/parsec@sha256:...) so an unrelated retag does not change what you deploy.
Browser client
Looking for a batteries-included wrapper? The JavaScript Client (parsec-client) page documents an opinionated
@frankbardon/parsec-clientpackage that adds token refresh, channel-name validation, manifest-driven transport selection, and coded error mapping on top ofcentrifuge-js. The page you are reading remains the bare-metalcentrifuge-jswalkthrough — useful when you want full control or are stitching parsec into an existing client.
This page walks through the full browser ↔ parsec round-trip:
- A Go server hosts parsec and an HTML page.
- The browser connects to the websocket transport using
centrifuge-js. - Public channels need no token; private channels present a JWT access token at connect time.
Parsec speaks the Centrifugo wire protocol unchanged, so the official
JS client is a drop-in. Every example below uses centrifuge-js
directly.
1. Public channel — no auth
The shortest demo. Boot a parsec instance, open a public channel, mount the websocket handler, serve an HTML page.
// examples/browser/main.go (excerpt)
p, _ := parsec.New(parsec.Options{StateDir: dir})
go p.Run(ctx)
for !p.Broker().Started() { time.Sleep(5*time.Millisecond) }
p.OpenPublic("public:demo.system.heartbeat", time.Hour)
mux := http.NewServeMux()
mux.Handle("/connection/websocket",
centrifuge.NewWebsocketHandler(p.Broker().Node(), centrifuge.WebsocketConfig{}))
mux.Handle("/", http.FileServer(http.Dir("./public")))
http.ListenAndServe(":8000", mux)
The browser side is ~15 lines:
<script src="https://unpkg.com/centrifuge@5.4.0/dist/centrifuge.js"></script>
<script>
const c = new Centrifuge("ws://" + location.host + "/connection/websocket");
c.on("connected", () => console.log("connected"));
const sub = c.newSubscription("public:demo.system.heartbeat");
sub.on("publication", (ctx) => console.log("got:", ctx.data));
sub.subscribe();
c.connect();
</script>
Run the bundled example to see it live:
go run ./examples/browser
# open http://localhost:8000
See examples/browser/
for the full source.
2. Private channel — token-gated
Private channels require an access token. The flow:
- Server mints the token via
parsec.CreatePrivate(or the TwirpCreatePrivateRPC). - Server hands the token to the browser — typically as part of a
session-bootstrap response or a dedicated
GET /tokenendpoint gated by your existing app session. - Browser presents the token to centrifuge via
setToken(...)before connect. - Parsec verifies + authorizes subscribe against the access
token’s
chslist and pattern scopes.
Server (Go)
// mintToken returns the access token for the current user.
func mintToken(p *parsec.Parsec, userID string) (string, error) {
creds, err := p.CreatePrivate(userID,
fmt.Sprintf("private:webapp.user.%s.downloads", userID),
30*time.Minute)
if err != nil {
return "", err
}
return creds.AccessToken, nil
}
// HTTP handler: returns { token, channel } as JSON. Your app's session
// guard runs first — only authenticated users get a token.
func tokenHandler(p *parsec.Parsec) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid := sessionUserID(r) // your auth glue
if uid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tok, err := mintToken(p, uid)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"token": tok,
"channel": fmt.Sprintf("private:webapp.user.%s.downloads", uid),
})
}
}
Mount it alongside the websocket handler:
mux.HandleFunc("/token", tokenHandler(p))
mux.Handle("/connection/websocket",
centrifuge.NewWebsocketHandler(p.Broker().Node(), centrifuge.WebsocketConfig{}))
Browser
<script src="https://unpkg.com/centrifuge@5.4.0/dist/centrifuge.js"></script>
<script>
async function main() {
// Your app's auth cookie carries the user identity into /token.
const res = await fetch("/token", { credentials: "include" });
if (!res.ok) throw new Error("token mint failed");
const { token, channel } = await res.json();
const c = new Centrifuge("ws://" + location.host + "/connection/websocket", {
token,
});
const sub = c.newSubscription(channel);
sub.on("publication", (ctx) => console.log("got:", ctx.data));
sub.on("error", (ctx) => console.error("sub error:", ctx));
sub.subscribe();
c.connect();
}
main().catch(console.error);
</script>
Refresh — keeping the connection alive past 5 minutes
Access tokens are short-lived (5 min default). When the server says the
token is about to expire, centrifuge-js fires a refresh event — the
client exchanges the refresh token for a fresh access token via
parsec’s public RefreshToken RPC:
const c = new Centrifuge("ws://" + location.host + "/connection/websocket", {
token,
getToken: async (ctx) => {
// Exchange the refresh token (held server-side) for a new access
// token. The /refresh endpoint is your shim around the parsec
// RefreshToken RPC.
const res = await fetch("/refresh", { credentials: "include" });
const { token } = await res.json();
return token;
},
});
On the server, /refresh calls p.RefreshAccess(refreshToken):
func refreshHandler(p *parsec.Parsec) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
refreshTok := sessionRefreshToken(r) // your storage
out, err := p.RefreshAccess(refreshTok)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(map[string]string{"token": out.AccessToken})
}
}
The refresh token can be stored in an HttpOnly cookie or a server-side session — never expose it to JavaScript directly.
3. WebTransport (HTTP/3)
If WebTransport is enabled, the centrifuge-js
options take a webtransport: true toggle:
new Centrifuge([
{ transport: "webtransport", endpoint: "https://" + location.host + ":8443/connection/webtransport" },
{ transport: "websocket", endpoint: "ws://" + location.host + "/connection/websocket" },
])
The client tries WebTransport first; on failure (network restrictions, TLS issues, older browsers) it falls back to WebSocket.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Connect succeeds, subscribe rejects with permission denied | Access token missing chs for the channel, or its scopes deny the verb | Mint via CreatePrivate for that channel name; check scope grants. |
| Centrifuge logs “bad request” on connect | Token expired between mint and use | Shorten the mint→connect window; use getToken for live refresh. |
Page loads, websocket immediately closes with transport: token is invalid | Server rotated keys; cached token signed by retired key | Re-mint the token; check parsec keys list. |
404 on /connection/websocket | Handler not mounted | mux.Handle("/connection/websocket", centrifuge.NewWebsocketHandler(...)) |
For deeper protocol questions, see the upstream centrifuge-js docs. The parsec server speaks that protocol verbatim — anything that works against Centrifugo works against parsec.
JavaScript Client (@frankbardon/parsec-client)
@frankbardon/parsec-client is a thin composition wrapper around
centrifuge-js that
adds the Parsec conventions a real browser app needs: token refresh
with rotation, channel-name validation, manifest-driven transport
selection, coded errors that mirror errors/codes.go, and a (decode-
only) scope inspector for UIs that need to display token grants.
The package lives in-tree at clients/js/ and is published to npm
under @frankbardon/parsec-client. The Centrifugo wire protocol is
unchanged — anything you can do with raw centrifuge-js (see
Browser client) you can still do.
Install
npm install @frankbardon/parsec-client
centrifuge ships as a direct dependency — no extra install needed.
Quickstart
import { ParsecClient } from "@frankbardon/parsec-client";
const client = new ParsecClient({
endpoint: "https://parsec.example.com",
accessToken: initialAccess,
refreshToken: initialRefresh,
accessExp: initialAccessExp, // unix seconds; 0 forces immediate refresh
});
client.on("terminal", (err) => console.error("re-authenticate:", err.code));
await client.connect();
const sub = client.newSubscription("public:webapp.lobby.global");
sub.on("publication", ({ data }) => console.log(data));
await sub.subscribe();
await client.publish("public:webapp.lobby.global", { kind: "hello" });
Manifest auto-discovery
connect() GETs /manifest, intersects the advertised transports
with the client’s preference list, and builds the endpoint array
centrifuge-js expects. The default client preference is
["websocket", "http_stream"]; override with preferTransports and
listen for manifestUnavailable to detect manifest fetch failures:
const client = new ParsecClient({
endpoint: "https://parsec.example.com",
preferTransports: ["webtransport", "websocket", "http_stream"],
});
client.on("manifestUnavailable", (err) => console.warn("manifest fetch failed", err));
To skip the manifest fetch entirely (e.g. tightly-locked-down
deployments behind a CDN that does not expose /manifest):
new ParsecClient({ endpoint: "...", manifest: false });
Token refresh and rotation
The wrapper plumbs centrifuge-js’s getToken callback into a
single-flight refresh state machine. A preemptive timer fires at
accessExp − skew − jitter so refresh almost always happens before
centrifuge has to ask. The refresh hits the Twirp RefreshToken RPC
at /twirp/parsec.ParsecService/RefreshToken.
Failure policy:
| Class | Examples | Action |
|---|---|---|
| Terminal | PARSEC_AUTH_DENIED, PARSEC_AUTH_EXPIRED | Clear store, emit terminal, return null to centrifuge (disconnects). Host app re-authenticates. |
| Recoverable | Network, PARSEC_BROKER_NOT_READY, PARSEC_RATE_LIMITED | Throw — centrifuge handles its own backoff and will retry. |
The wrapper always persists the response’s refresh_token (even when
rotated: false), and it persists BEFORE resolving the promise to
centrifuge. Reusing a stale refresh after rotation triggers
family-revoke — the wrapper avoids that path by design. See the
Refresh-Token Rotation runbook for the
server side.
Plugging in custom storage
MemoryTokenStore is the default. For session continuity across page
reloads, implement the TokenStore interface against your storage of
choice (cookies, IndexedDB, a session-storage adapter):
import type { TokenStore, TokenPair } from "@frankbardon/parsec-client";
class IndexedDbTokenStore implements TokenStore {
async get(): Promise<TokenPair | null> { /* ... */ return null; }
async set(pair: TokenPair): Promise<void> { /* ... */ }
async clear(): Promise<void> { /* ... */ }
}
new ParsecClient({ endpoint: "...", tokenStore: new IndexedDbTokenStore() });
Coded errors
Every error thrown by the wrapper is a ParsecError carrying a
PARSEC_* code that mirrors errors/codes.go. Branch on .code
instead of string-matching messages:
import { ParsecError, ParsecErrorCode } from "@frankbardon/parsec-client";
try {
await client.publish(channel, payload);
} catch (err) {
if (err instanceof ParsecError && err.code === ParsecErrorCode.RateLimited) {
// back off
}
}
The Twirp wire mapping mirrors internal/rpcclient/errors.go:
unauthenticated → PARSEC_AUTH_EXPIRED, permission_denied → PARSEC_AUTH_DENIED, resource_exhausted → PARSEC_RATE_LIMITED, etc.
Channel name validation
parseName and buildName mirror channels.ParseName in Go.
Subscribe / publish call parseName before delegating, so malformed
channels fail with PARSEC_CHANNEL_INVALID before centrifuge ever
sees them. You can call the helpers directly to validate
operator-typed input:
import { parseName, buildName, Visibility } from "@frankbardon/parsec-client/channels";
const name = buildName(Visibility.Private, "webapp", "user", userId, "inbox");
// → "private:webapp.user.<id>.inbox", validated end-to-end
Scope inspector — decode only
inspectScopes decodes the JWT payload and returns a typed view a UI
can render. It does NOT verify the signature. The server is the
only authority on token validity; treat the result as display data,
not as an authorization decision.
import { inspectScopes } from "@frankbardon/parsec-client/scopes";
const view = inspectScopes(accessToken);
// view.channels, view.scopes (pattern/verbs/deny), view.expiresAt, view.expired
Codegen interop (parsec-gen --lang ts)
parsec-gen --lang ts emits pattern constants and per-aspect payload
interfaces from your schema registry. Pair them with typedSubscribe
for end-to-end types:
import { sessionsChannel, type SessionsCursor } from "./gen/index";
const sub = client.typedSubscribe<SessionsCursor>(sessionsChannel(userId));
sub.on("publication", ({ data }) => {
// data: SessionsCursor
});
await sub.subscribe();
The T parameter is a phantom type — runtime is the same
Subscription centrifuge-js returns. The type is a promise, not a
guarantee: the schema registry is the contract enforcer. Wire your
emitter, regenerate after schema changes, and the types will track.
Versioning
The npm package versions independently from the Go server. Each
release declares a minimum Parsec server version. A server release
that bumps descriptor.FormatVersion is a wire change and requires a
matching client release.
Reference
| Export | Module | Purpose |
|---|---|---|
ParsecClient | @frankbardon/parsec-client | Composition wrapper |
ParsecError, ParsecErrorCode | @frankbardon/parsec-client | Coded errors |
parseName, buildName, Visibility | @frankbardon/parsec-client/channels | Channel grammar |
inspectScopes, decodeJwtPayload | @frankbardon/parsec-client/scopes | Unverified JWT introspection |
MemoryTokenStore, TokenStore | @frankbardon/parsec-client | Token persistence boundary |
fetchManifest, pickTransports | @frankbardon/parsec-client | Manifest + transport ranking |
Channel Naming Convention
Channel names are the contract between publishers and subscribers. Parsec enforces a strict layout so every name announces its visibility, its owning application, and the domain object it speaks for.
Grammar
<visibility>:<app>.<domain>[.<id>][.<topic>]
visibility—publicorprivate. Required. Maps to a Centrifuge namespace at boot. Anything else is rejected at validation time.app— a short slug for the consuming application (e.g.webapp,agent,cron). Required. Lowercase ASCII letters, digits, and hyphens.domain— the root domain object (session,user,download,analysis,alert,heartbeat,report,scoreboard). Required.id— a stable identifier (UUID, session ID, user ID). Required for private; optional for public when the channel is broadcast-wide.topic— optional sub-topic for finer-grained subscription (progress,complete,failed).
Components are joined with . after the : separator. The : is reserved
for the visibility namespace and must not appear elsewhere.
Examples
Public
| Channel | Meaning |
|---|---|
public:webapp.system.status | System-wide status feed for the webapp. |
public:scoreboard.counter.ai_users | Counter that anyone in the org can subscribe to. |
public:agent.broadcast.maintenance | Maintenance announcements from the agent runtime. |
Private
| Channel | Meaning |
|---|---|
private:webapp.session.{sid}.notifications | Per-session notification stream. |
private:webapp.user.{uid}.downloads | Per-user async-download progress. |
private:agent.analysis.{job}.progress | Per-job analysis progress. |
Why this matters
- A grep for
public:finds everything you can subscribe to without auth. - A grep for
private:finds everything that needs a token. - The
app.domainprefix tells you who owns it — kill that namespace and you cleanly remove a consumer. - The
idsegment makes per-tenant fanout cheap: subscribers do not need server-side filters.
Domain hygiene
Domain names are project-specific, but the foundation is fixed:
- Keep
domainto a small vocabulary perapp. If you have more than ~12 domains, you probably want a new app slug. - Treat
domainplurality consistently. We use the singular form (session, notsessions). - Reserve a
systemdomain in every app for service-level events.
The full enforcement lives in channels/name.go and is exercised by the
CLI and the Twirp surface alike.
Patterns
The same grammar — with two extra wildcards — describes the patterns
that may appear in a token’s scopes claim:
*matches exactly one segment, in any position.**matches one-or-more trailing segments; last position only.
Patterns are bound to a concrete visibility (public or private);
cross-visibility patterns like *:foo.bar are rejected at parse time.
See Access Control Scopes for how scope patterns interact
with the chs exact-match list and the verb permissions.
Public channels
Public channels are broadcast feeds: anyone can subscribe, no token required.
./bin/parsec channels open public:webapp.system.status
echo '{"msg":"deploy started"}' | ./bin/parsec publish public:webapp.system.status
./bin/parsec subscribe public:webapp.system.status
The visibility prefix public: is what unlocks the no-auth subscribe
path. The grammar is documented in naming; anything not
matching public:<app>.<domain>[.<id>][.<topic>] is rejected by
channels.ParseName before the manager ever sees it.
Lifecycle
Public channels move between three states:
| State | Meaning | New subscribers | Existing subscribers |
|---|---|---|---|
open | Active | Accepted | Receive publications |
closed | Inactivity TTL exceeded | Rejected | Drain naturally (not kicked) |
deleted | Removed by an operator | Rejected | Unsubscribed |
The transition from open to closed happens during the manager’s
periodic sweep — every 30 seconds by default. A closed channel can be
reopened with another channels open call, which resets last_active
and emits a fresh opened event.
TTL semantics
Public channels have an inactivity TTL: any Publish (or explicit
Touch) resets the clock. The default is 30 minutes; override with
--ttl 2h on channels open. Two important properties:
- Closed public channels are not removed. The record stays in the manager so an operator can reopen it without losing the name.
- Closed public channels do not kick subscribers. They simply refuse new ones. This matches the “fade out” intent of a public feed — the broadcaster has stopped, but the people already listening can finish what they’re hearing.
See TTL and expiry for the full lifecycle diagram and how it differs from private.
History
Centrifuge keeps a per-channel history buffer so a late subscriber can
catch up. The defaults: 100 publications, 5-minute retention. These are
broker-side knobs configured at boot via broker.Options:
HistorySize— bound on retained publications per channelPublicHistoryTTL— how long publications stay in history
Both default to safe single-node values. Embedders who need different
windows set them through parsec.Options.BrokerOptions.
Delete
Public channels do not auto-delete (only private does). You explicitly remove one with:
./bin/parsec channels delete public:webapp.system.status
Delete is final. The manager flips the record to deleted, the broker
unsubscribes every connected client, and the name becomes unusable —
even reopening it returns PARSEC_CHANNEL_NOT_FOUND. Create a new
channel under a different name (or wait for the manager state to be
wiped on restart).
Typical use
- System status feeds:
public:<app>.system.status - Org-wide counters:
public:scoreboard.counter.<metric> - Maintenance announcements:
public:<app>.broadcast.maintenance
Anything that should reach an authenticated population belongs in a private channel instead.
Private channels
Private channels are token-gated, time-boxed feeds: a subscriber must present a valid access token tied to the channel name and subject.
./bin/parsec channels create private:webapp.user.42.downloads \
--subject user-42 --ttl 30m
# {
# "code": "OK",
# "payload": {
# "name": "private:webapp.user.42.downloads",
# "access_token": "eyJ...",
# "refresh_token": "eyJ...",
# "access_expires_unix": 1747935432,
# "refresh_expires_unix": 1747937232
# }
# }
The grammar (channels.ParseName) requires private channels to carry
an id segment; private:webapp.user.downloads is invalid because there
is no per-tenant scope. See naming for the full rule set.
What create returns
channels create mints a brand-new channel record AND issues the token
pair in one round trip. The response carries:
access_token— short-lived (default 5 minutes; bounded byparsec.Options.AccessTokenTTL).refresh_token— lives up to the channel TTL, capped at 1 hour. Use it to mint fresh access tokens until the channel expires.access_expires_unix/refresh_expires_unix— absolute expiry timestamps.
The server does NOT keep the tokens. Lose them and you must recreate the channel. This is intentional: Parsec stores channel records, not secret material.
TTL cap
The hard ceiling is one hour. The validator that enforces it is in
channels/manager.go:
const MaxPrivateTTL = 1 * time.Hour
Requesting more returns PARSEC_CHANNEL_TTL_EXCEEDS_MAX. Most
deployments use 5–30 minutes; the upper bound is meant for long-running
download or analysis jobs, not for ambient session channels.
Lifecycle
Private channels move through:
| State | Meaning | Effect on subscribers |
|---|---|---|
open | Active. Accepts subscribes with a valid access token. | Receive publications. |
deleted | Inactivity TTL exceeded OR explicit delete. | Unsubscribed. |
Note the absence of a closed state. Private channels do not “fade
out” — when their TTL elapses they are reaped from the manager
entirely. The reason: a closed-but-not-deleted private channel would
leak the channel’s existence to anyone who asked, defeating the
visibility prefix.
Refresh flow
Once an access token expires, the client exchanges its refresh token for a new access:
./bin/parsec tokens refresh "<refresh-token>"
RefreshToken is a public RPC — no bearer required — because it
validates the refresh token in the body. The new access cannot outlive
the refresh. When the refresh expires the channel is effectively
unreachable; the next sweep reaps it.
Touch and Publish
Every successful Publish calls Manager.Touch under the hood, which
resets LastActive. A heavily-used channel stays alive past its TTL by
the publishing client merely doing its job. An idle channel ages out
on its own.
If a client wants to keep a channel alive without sending data, the
library exposes Manager.Touch(name) directly. The CLI does not — there
is no operator use case.
Typical use
- Per-user notifications:
private:webapp.user.<uid>.<topic> - Per-job progress:
private:agent.analysis.<job>.progress - Per-run failure streams:
private:tools.run.<run_id>.failures
See also public channels and TTL and expiry for how the lifecycle differs.
Pattern-based grants
Tokens can carry a scopes claim — a set of channel-name pattern
grants with per-verb permissions. A single token can authorize a whole
namespace prefix like private:webapp.user.42.* instead of naming
every channel individually. See Access Control Scopes for the
full grammar and examples.
TTL and expiry
Every channel carries an inactivity TTL; the manager’s sweep loop is what enforces it.
# Open with an explicit TTL
./bin/parsec channels open public:webapp.system.status --ttl 1h
# Inspect TTL + last_active
./bin/parsec channels get public:webapp.system.status
States
Three states, one transition diagram:
+-------+
| open |
+-------+
/ \
sweep (public, sweep (private,
TTL exceeded) TTL exceeded)
| |
v v
+--------+ +---------+
| closed | | deleted |
+--------+ +---------+
\ ^
reopen \ / explicit Delete
v /
+-------+
| open | (closed -> open via OpenPublic)
+-------+
The constants live in channels/manager.go:
const MaxPrivateTTL = 1 * time.Hour
const DefaultPublicTTL = 30 * time.Minute
Default private TTL is 15 minutes — see CreatePrivate in the same
file.
Sweep behavior
Manager.Sweep(now) runs once per SweepInterval (default 30s).
For every channel in StateOpen:
- If
now.Sub(LastActive) < TTL— skip. - Else, if the name is
public:— flip toclosed, emitEventClosed. Existing subscribers stay attached and continue to receive publications until they disconnect on their own. - Else (
private:) — remove the record entirely, emitEventDeleted. The broker bridge kicks every connected subscriber.
That asymmetry is intentional. Public channels are “broadcasts” — the broadcaster fading out should not look the same as a private per-tenant feed silently disappearing.
Touch
A channel resets its LastActive on three occasions:
OpenPublicon a closed channel — reopens and resets.Publish— every publish path callsTouchon success.- An explicit
Manager.Touchcall from library code.
There is no CLI touch subcommand because operator workflows do not
need one. If you want to keep a channel alive without sending data,
either republish heartbeat messages or extend the TTL by calling
channels open again with a larger --ttl.
Public vs private at a glance
| Behavior | Public | Private |
|---|---|---|
| Default TTL | 30m | 15m |
| Max TTL | unbounded | 1h |
| TTL exceeded → | closed (record kept) | deleted (record reaped) |
| Subscribers when expired | drain naturally | kicked |
| Reopenable | yes (channels open again) | no — create fresh |
| Auto-delete | never | on TTL |
See public channels and private channels for the surface-by-surface details, and the architecture overview for how the manager-event bridge translates sweep events into broker actions.
Events
The manager emits typed events on every transition:
| Kind | When | Bridge effect |
|---|---|---|
opened | Public re-open or private create | New subscribers accepted |
closed | Public TTL exceeded | No new arrivals; existing drain |
deleted | Explicit delete or private TTL exceeded | Broker unsubscribes everyone |
Subscribers register via Manager.Subscribe(chan<- Event); the manager
sends non-blocking, so subscribers should buffer. See
broker internals for the wire side.
Access Control Scopes
Parsec tokens carry two grant sources:
chs— an exact-match list of channel names. A token whosechscontainsprivate:webapp.user.42.profilecan subscribe to that channel and nothing else.scopes— a set of channel-name pattern grants. Each scope binds a pattern to a list of verbs (subscribe,publish,manage).
A token may carry one or both. The check at subscribe/publish time is the union: any path that authorizes is sufficient.
Why patterns
Pure chs requires the operator to know every channel a token may
touch at issuance time. For multi-tenant SaaS workloads where a user
owns a namespace prefix and channels are created on the fly inside that
prefix, this means either pre-minting hundreds of tokens or
re-issuing constantly.
Scopes let a single token say “this user owns the prefix
private:webapp.user.42.” and survive every new channel created under
that prefix until the token expires.
Grammar
A scope pattern is a channel name with two wildcard primitives layered on top:
<visibility>:<seg>(.<seg>)*[.*][.**]
<visibility>must bepublicorprivate. Cross-visibility patterns (*:foo.bar) are rejected at issuance withPARSEC_INVALID_ARGUMENT.<seg>is a literal channel-name segment (lowercase ASCII, digits,-,_).*matches exactly one segment, in any position.**matches one-or-more trailing segments; it is only legal as the last segment of the pattern.- The total segment count is capped at 8 — pathological tokens with
**.**.**...are rejected.
Truth table
| Pattern | Channel | Matches? |
|---|---|---|
private:foo.bar.7 | private:foo.bar.7 | yes |
private:foo.bar.7 | private:foo.bar.8 | no |
private:foo.bar.* | private:foo.bar.7 | yes |
private:foo.bar.* | private:foo.bar.7.x | no |
private:foo.bar.** | private:foo.bar.7 | yes |
private:foo.bar.** | private:foo.bar.7.x.y | yes |
private:foo.* | public:foo.bar | no |
*:foo.bar | (any) | rejected at parse |
Verbs
Three verbs are recognized:
| Verb | Where it applies |
|---|---|
subscribe | the broker’s subscribe authorizer. A chs exact-match entry authorizes every verb (including subscribe). |
publish | client-side publish over the websocket. Server-side publishes via the management RPC remain gated by the mgmt bearer. |
manage | per-channel management RPCs when called with an access token instead of a mgmt token (delegated administration). |
A scope must list at least one verb. The check at runtime requires the
exact verb the surface is asking for — a subscribe-only scope cannot
publish, even on a channel the pattern matches.
Example token
A scope-bearing access token’s payload looks like this:
{
"sub": "user-42",
"typ": "access",
"chs": ["private:webapp.user.42.profile"],
"scopes": [
{
"pat": "private:webapp.user.42.**",
"v": ["subscribe", "publish"]
}
],
"iat": 1700000000,
"exp": 1700000300
}
This token can:
- subscribe to
private:webapp.user.42.profile(Chs exact match — any verb) - subscribe to
private:webapp.user.42.downloads(scope match + verb) - subscribe to
private:webapp.user.42.session.notifications(**match) - publish to
private:webapp.user.42.profile(scope match + verb)
It cannot subscribe to private:webapp.user.43.profile
(scope does not match).
chs-only tokens
A token with only chs and no scopes is the simplest shape: the
exact-match list is checked, and any name in the list authorizes every
verb. A token with both chs and scopes widens the grant — the
authorization check is the union.
Deny scopes
A scope may set deny: true to subtract from the grant set. The
on-wire shape is identical to an allow scope plus the new field:
{
"pat": "private:webapp.user.42.financials",
"v": ["subscribe"],
"deny": true
}
The deny key is omitted from the JSON when false, so allow-only
tokens stay compact on the wire.
Precedence: deny wins
Claims.Authorizes evaluates a single (channel, verb) request in this
order:
- Deny pass. Walk every scope where
deny: true. If the pattern matches the channel AND the verb is in the scope’s verb list, return deny immediately. chspass. Walk thechsexact-match list. Any entry equal to the channel returns allow.- Allow pass. Walk every scope where
deny: false. If the pattern matches the channel AND the verb is in the verb list, return allow. - Otherwise return deny.
A matching deny always wins — it overrides both chs exact matches
and any overlapping allow scope. This mirrors AWS IAM, GCP IAM, and
similar systems: deny is non-overridable, so operators can carve a hole
in a permissive grant without having to enumerate every channel they
do want to allow.
A deny scope without any overlapping allow is a defensive no-op: the channel already had no grant; the deny does not unlock anything.
The manifest exposes this with two fields:
{
"deny_supported": true,
"scope_precedence": "deny-wins"
}
Worked example
The operator wants user-42 to subscribe to anywhere under their user
prefix EXCEPT a sensitive financials channel.
parsec channels create private:webapp.user.42.profile \
--subject user-42 \
--scope "private:webapp.user.42.*" \
--deny-scope "private:webapp.user.42.financials" \
--verb subscribe
The minted access token’s scope set is:
[
{"pat": "private:webapp.user.42.*", "v": ["subscribe"]},
{"pat": "private:webapp.user.42.financials", "v": ["subscribe"], "deny": true}
]
At subscribe time:
| Channel | Allow matches? | Deny matches? | Result |
|---|---|---|---|
private:webapp.user.42.profile | yes | no | allow |
private:webapp.user.42.downloads | yes | no | allow |
private:webapp.user.42.financials | yes | yes | deny |
private:webapp.user.43.profile | no | no | deny (no allow) |
CLI flags
parsec channels create accepts two new flags alongside --scope and
--verb:
--deny-scope <pattern>(repeatable) — adds a deny scope.--deny-verb <verb>(repeatable) — verb list the deny scopes apply to. When omitted, the deny scopes inherit the--verblist (defaultsubscribe).
Security note: deny patterns are visible in the token
The deny scope rides in the access token in clear (signed but not
encrypted, like every other claim). An operator that mints a token
with deny: private:tenant.x.secret-project is telling the holder of
that token “secret-project exists.” Do not treat deny patterns as
confidential — if the channel name itself is sensitive, the deny
belongs at the broker level (a server-side ACL Parsec does not yet
ship), not in the token.
The trade-off is intentional: keeping deny inside the token avoids a round-trip per subscribe and keeps the authorization decision local to the broker’s connection.
Issuance
Over the CLI:
parsec channels create private:webapp.user.42.profile \
--subject user-42 \
--scope "private:webapp.user.42.*" \
--scope "private:webapp.user.42.session.*" \
--verb subscribe \
--verb publish
Each --scope adds a pattern; --verb flags apply to every scope on
the command. If no --verb is given, subscribe is the default.
Over the Twirp RPC (CreatePrivate):
{
"name": "private:webapp.user.42.profile",
"subject": "user-42",
"ttlSeconds": 900,
"scopes": [
{"pattern": "private:webapp.user.42.*", "verbs": ["subscribe"]}
]
}
Programmatic Go:
creds, err := svc.CreatePrivateChannel(ctx, "user-42",
"private:webapp.user.42.profile", 15*time.Minute,
[]auth.Scope{
{Pattern: "private:webapp.user.42.**", Verbs: []auth.Verb{auth.VerbSubscribe}},
})
Performance
The pattern matcher is hand-rolled (no regex) and runs in well under
500ns per Matches call. Parsed patterns are cached on the Scope value
the first time the matcher consults them — subsequent calls reuse the
compiled segments without re-parsing. There are no string allocations
in the hot path.
When scopes is empty, Claims.Authorizes short-circuits to the
chs exact-match loop, so tokens without pattern grants pay zero
overhead for the pattern matcher.
parsec serve
Boot the broker and the HTTP service (Twirp, websocket, SSE, healthz) on one address.
./bin/parsec serve --addr :8000 --state-dir /var/lib/parsec
# parsec serve: loaded keyring from /var/lib/parsec/keyring.json (active=k0)
# parsec serve: bootstrap mgmt token (expires 2026-05-23 18:04:11 UTC):
# eyJhbGciOiJIUzI1NiIs...
What it does
serve is the only long-running subcommand. It:
- Builds a
*parsec.Parsecwith the flag-drivenOptions. - Loads (or bootstraps) the keyring at
<state-dir>/keyring.json. Without--state-dirthe ring is ephemeral and tokens die on restart. - Mints a bootstrap mgmt token under the active key and prints it to stderr — copy it before the server scrolls it off.
- Starts the Centrifuge node, the channel-manager sweeper, the manager-to-broker event bridge, and (when persistent) the keyring-file watcher.
- Serves Twirp at
/twirp/parsec.ParsecService/, the websocket at/connection/websocket, SSE at/sse, and the manifest at/manifest.
Stop with SIGINT / SIGTERM. The broker drains over a 5-second
shutdown grace period before the process exits.
Operator-disabled auth
--no-auth removes the bearer middleware from the management RPC. The
flag exists for local development against a throwaway broker; use it
anywhere production-shaped and an attacker can mutate channels at will.
Parsec logs a loud warning at boot to make accidents loud.
Persistent state
--state-dir controls the keyring file path. The directory is created
with mode 0700; the file with mode 0600. Channel records are NOT
persisted — only the HMAC ring is. See
deployment for the full persistence stance and
key rotation for the rotation runbook.
Reference
NAME:
parsec serve - Boot the parsec broker and HTTP/Twirp service
USAGE:
parsec serve [options]
OPTIONS:
--addr string, -a string Listen address (host:port) (default: ":8000")
--state-dir string Directory holding keyring.json (created with 0700 if missing). If unset, the keyring is ephemeral. [$PARSEC_STATE_DIR]
--mgmt-subject string Subject (sub claim) for the bootstrap mgmt token printed at boot (default: "operator") [$PARSEC_MGMT_SUBJECT]
--mgmt-ttl duration Lifetime of the bootstrap mgmt token printed at boot (default: 24h0m0s) [$PARSEC_MGMT_TTL]
--keyring-poll duration Interval between keyring.json mtime checks. 0 disables polling. (default: 5s) [$PARSEC_KEYRING_POLL]
--no-auth Disable bearer auth on the management RPC. DANGEROUS — local development only. [$PARSEC_NO_AUTH]
--help, -h show help
GLOBAL OPTIONS:
--json Output the parsec manifest as a descriptor envelope
See also
- channels — operate on the broker once it is up.
- Key rotation runbook.
- Deployment.
parsec channels
Manage channels on a remote Parsec server.
export PARSEC_TOKEN=$(cat ~/.parsec/mgmt-token)
export PARSEC_SERVER=https://parsec.example.com
./bin/parsec channels list
./bin/parsec channels open public:webapp.system.status --ttl 1h
./bin/parsec channels create private:webapp.user.42.downloads --subject user-42 --ttl 30m
./bin/parsec channels get private:webapp.user.42.downloads
./bin/parsec channels delete public:webapp.system.status
Every subcommand routes to the Twirp service at
/twirp/parsec.ParsecService/. The CLI is a thin adapter — anything
you can do here is reachable over Twirp.
Auth
The --server and --token flags (or PARSEC_SERVER /
PARSEC_TOKEN) are required. Tokens are mgmt bearers minted at boot
by parsec serve or rotated via the
key-rotation runbook.
list
Returns every channel the manager currently knows about, including
those in closed state. Deleted records do not appear.
NAME:
parsec channels list - List all managed channels
USAGE:
parsec channels list [options]
OPTIONS:
--help, -h show help
GLOBAL OPTIONS:
--json Output the parsec manifest as a descriptor envelope
open
Opens (or reopens) a public channel. Idempotent — calling it on an
existing open channel resets LastActive and the TTL.
NAME:
parsec channels open - Open (or re-open) a public channel
USAGE:
parsec channels open [options] <channel-name>
OPTIONS:
--ttl duration Inactivity TTL before close (default: 30m0s)
--help, -h show help
GLOBAL OPTIONS:
--json Output the parsec manifest as a descriptor envelope
create
Mints a private channel and returns its one-shot access + refresh tokens. The TTL is hard-capped at 1 hour. See private channels for the lifecycle.
NAME:
parsec channels create - Create a private channel and print its one-shot access + refresh tokens
USAGE:
parsec channels create [options] <channel-name>
OPTIONS:
--ttl duration Inactivity TTL (max 1h) (default: 15m0s)
--subject string, -s string Subject (sub claim) embedded in the tokens; typically a user id
--scope string (repeatable) Channel-name pattern grant baked into the issued tokens (e.g. private:webapp.user.42.*)
--verb string (repeatable) Verb granted on the listed scopes. Allowed: subscribe, publish, manage. Default: subscribe
--help, -h show help
GLOBAL OPTIONS:
--json Output the parsec manifest as a descriptor envelope
Pattern grants (–scope / –verb)
Beyond the exact-match chs claim, the issued tokens may carry a set
of pattern-based grants in the scopes claim. Each --scope adds a
channel-name pattern; --verb flags apply to every pattern on the
command. With no --verb, the default is subscribe.
./bin/parsec channels create private:webapp.user.42.profile \
--subject user-42 \
--scope "private:webapp.user.42.*" \
--scope "private:webapp.user.42.session.*" \
--verb subscribe --verb publish
See Access Control Scopes for the grammar, truth table, and verb semantics.
get
Single-channel snapshot. Returns PARSEC_CHANNEL_NOT_FOUND if the
channel was never opened or has been deleted.
NAME:
parsec channels get - Show a single channel's metadata
USAGE:
parsec channels get [options] <channel-name>
OPTIONS:
--help, -h show help
GLOBAL OPTIONS:
--json Output the parsec manifest as a descriptor envelope
delete
Final. Removes the channel from the manager and unsubscribes every
connected client. Private channels normally auto-delete on TTL; you
rarely call delete for them. Public channels require an explicit
delete (or a server restart, since channel state is in-memory).
NAME:
parsec channels delete - Delete a channel (public channels must be deleted explicitly; private auto-expire)
USAGE:
parsec channels delete [options] <channel-name>
OPTIONS:
--help, -h show help
GLOBAL OPTIONS:
--json Output the parsec manifest as a descriptor envelope
Reference
NAME:
parsec channels - Manage channels on a remote parsec server
USAGE:
parsec channels [command [command options]]
COMMANDS:
list List all managed channels
open Open (or re-open) a public channel
create Create a private channel and print its one-shot access + refresh tokens
get Show a single channel's metadata
delete Delete a channel (public channels must be deleted explicitly; private auto-expire)
OPTIONS:
--server string Parsec server base URL (default: "http://localhost:8000") [$PARSEC_SERVER]
--token string Mgmt bearer token for the management API [$PARSEC_TOKEN]
--help, -h show help
See also
- publish — send data once a channel exists.
- Channel naming convention.
- Twirp service for the wire shape.
parsec publish
Push a single message to a channel.
# Inline body
./bin/parsec publish public:webapp.system.status --data '{"msg":"hello"}'
# From a file
./bin/parsec publish public:webapp.system.status --file ./payload.json
# From stdin (pipe-friendly)
echo '{"msg":"hello"}' | ./bin/parsec publish public:webapp.system.status
What it does
publish calls the Publish RPC. The server validates the channel
name through channels.ParseName, confirms the channel exists and is
open, ships the data to Centrifuge, and touches LastActive so the
sweeper does not close the channel out from under an active publisher.
Bodies are opaque bytes. Parsec does not impose a schema; the contract is between the publisher and the subscriber. JSON is conventional because every consumer language can parse it cheaply, but you can ship protobuf, msgpack, or raw text just as easily.
Source precedence
Exactly one source is honored, picked in this order:
--data/-d— inline.--file/-f— read the named file.- stdin — only when neither of the above is set.
Mixing sources is an operator error and the CLI returns
PARSEC_INVALID_ARGUMENT.
What you get back
The CLI prints a descriptor envelope with the broker’s publish ack:
{
"code": "OK",
"payload": {
"offset": 14,
"epoch": "abc123"
}
}
The offset is the per-channel monotonic publication index Centrifuge
assigns. Late subscribers use (offset, epoch) to ask for a recovery
stream — they catch up on the publications they missed.
Failure modes
| Code | Cause |
|---|---|
PARSEC_CHANNEL_INVALID | Name does not match the grammar. |
PARSEC_CHANNEL_NOT_FOUND | Channel was never opened, or expired. |
PARSEC_CHANNEL_CLOSED | Public channel past its TTL — reopen first. |
PARSEC_AUTH_DENIED | Missing or expired bearer. |
PARSEC_BROKER_NOT_READY | Server is still booting; retry shortly. |
For private channels, the bearer is the operator mgmt token — not the end-user access token. End users publish via the websocket.
Reference
NAME:
parsec publish - Publish a message to a channel
USAGE:
parsec publish [options] <channel-name>
OPTIONS:
--server string (default: "http://localhost:8000") [$PARSEC_SERVER]
--token string [$PARSEC_TOKEN]
--data string, -d string Inline message body
--file string, -f string Read message body from file
--help, -h show help
GLOBAL OPTIONS:
--json Output the parsec manifest as a descriptor envelope
See also
- channels — open/create before publishing.
- Library: PublishOrSink — fallback to a sink when no subscriber is listening.
parsec subscribe
Tail a parsec channel from the terminal. Implemented as a
Server-Sent Events (SSE) client against the server’s /sse?channel=<name>
endpoint. Intended as a debug probe, not a production transport —
production clients use the WebSocket (/connection/websocket) or
WebTransport (/connection/webtransport) endpoints.
Usage
parsec subscribe <channel-name>
Each publication appears on stdout as one NDJSON envelope:
{"channel":"public:webapp.system.status","offset":42,"data":{"msg":"hello"}}
Ctrl-C exits cleanly.
Flags
| Flag | Env | Default |
|---|---|---|
--server | PARSEC_SERVER | http://localhost:8000 |
--token | PARSEC_TOKEN | (empty) |
--from-offset | — | 0 (latest) |
For private channels, set --token to an access token (NOT a mgmt
bearer):
parsec subscribe private:webapp.user.42.downloads \
--token "$(parsec channels create private:webapp.user.42.downloads --subject u42 | jq -r .payload.access_token)"
Why not for production?
SSE polls. The server holds a long-lived HTTP connection but the
backpressure model and reconnection semantics are simpler than the
WebSocket transport — fine for a debug probe, suboptimal for browser
clients. Use centrifuge-js (or the parsec library directly inside a
Go service) for real workloads.
parsec tokens
Token operations. The subcommands split along auth boundary:
refreshis public — the refresh token in the body authenticates the call. Use it to re-mint an access token without holding a mgmt bearer.mgmt,revoke, andrevoke-userrequire a valid mgmt bearer.mgmtis used during key rotation;revoke/revoke-userare the operator-facing front door to theRevocationStore.
Flags
| Flag | Env | Default |
|---|---|---|
--server | PARSEC_SERVER | http://localhost:8000 |
--token | PARSEC_TOKEN | (empty — required for mgmt) |
tokens refresh
Exchange a refresh token for a fresh access token. The new access token cannot outlive the refresh expiry.
parsec tokens refresh "<refresh-token>"
Output (descriptor envelope):
{
"type": "parsec.token.refreshed",
"payload": {
"access_token": "eyJhbGciOi...",
"expires_at": "2026-05-22T18:14:11Z"
}
}
tokens mgmt
Mint a new mgmt bearer signed by the active key. The intended use is
mid-rotation: after parsec keys promote <new-kid>, the operator’s
current bearer is still signed by the old key. Calling tokens mgmt
issues a fresh bearer under the new key so the old one can safely
retire.
parsec tokens mgmt --subject ops-bot --ttl 24h
| Flag | Default | Notes |
|---|---|---|
--subject, -s | operator | Subject (sub claim) |
--ttl | 24h | Clamped to [1h, 7d] |
Output:
{
"type": "parsec.token.mgmt",
"payload": {
"mgmt_token": "eyJhbGciOi...",
"expires_at": "2026-05-23T17:08:42Z"
}
}
The new bearer typically goes straight into PARSEC_TOKEN:
export PARSEC_TOKEN=$(parsec tokens mgmt | jq -r .payload.mgmt_token)
See key rotation for the full rotation runbook.
tokens revoke
Mark a single access token revoked by its jti claim (the same value
the token broker returns as token_id in its issuance response). The
subscribe authorizer consults the store on every private subscribe and
denies revoked tokens with PARSEC_AUTH_DENIED.
parsec tokens revoke <token-id> --user <user-id> --reason "compromised"
| Flag | Default | Notes |
|---|---|---|
--user, -u | (empty) | Optional user-id recorded with the entry for audit |
--reason, -r | (empty) | Optional free-text reason recorded with the entry for audit |
Output:
{
"type": "parsec.token.revoked",
"payload": {
"token_id": "abcd1234...",
"user_id": "user-42",
"reason": "compromised"
}
}
The call returns PARSEC_INVALID_ARGUMENT when the server has no
RevocationStore wired (parsec.Options.RevocationStore left nil).
That’s a deliberate loud failure — silently no-op’ing here would mask
a security gap.
tokens revoke-user
Blanket-revoke every token previously issued to a user. The store records the wall-clock cutoff; tokens minted after the call remain valid (use this when a user re-authenticates after a compromise — old sessions die, new ones live).
parsec tokens revoke-user <user-id> --reason "password reset"
| Flag | Default | Notes |
|---|---|---|
--reason, -r | (empty) | Optional free-text reason recorded with the entry for audit |
See token broker for the operational runbook.
parsec keys
Manage HMAC signing keys. The keyring lives at
<state-dir>/keyring.json (file-backed) or in Redis (multi-node). The
CLI never edits the file directly except for export / import — every
other subcommand routes through the mgmt RPC so the server is the
single writer.
Flags
| Flag | Env | Default |
|---|---|---|
--server | PARSEC_SERVER | http://localhost:8000 |
--token | PARSEC_TOKEN | (empty — required) |
keys list
parsec keys list
Returns every key in the ring with its role (active, verify-only,
retired), creation timestamp, and short fingerprint. Retired keys
appear once in the result and then drop on the next snapshot.
keys generate
parsec keys generate
Mints a new 32-byte HMAC secret and joins it to the ring as
verify-only. The new key does NOT sign tokens yet — promote does
that. The kid is returned in the descriptor envelope.
keys promote
parsec keys promote <kid>
Makes <kid> the active signer. The previous active key demotes to
verify-only, so tokens already issued under it keep verifying until
they expire.
keys retire
parsec keys retire <kid>
parsec keys retire <kid> --notify https://parsec.eu-west.example.com:8000 \
--notify https://parsec.ap-south.example.com:8000
Stops the named key from verifying. Operators must wait the longest in-flight token TTL (default 24h for mgmt) before retiring a key, or existing tokens reject early.
--notify <peer-url> POSTs a ReloadKeys to each listed peer after
the local retire succeeds. Peers must share the keyring (via the
parsec-keys-sync daemon or a shared Redis) AND trust the bearer this
CLI presents. Individual peer failures are surfaced in the envelope but
never roll back the local retire — propagation is best-effort.
keys reload
parsec keys reload
Force the server to re-read its keyring file. Functionally identical to
sending SIGHUP to the parsec process. Use when the mtime-poll watcher
is disabled (--keyring-poll 0).
keys export
# from a file-backed ring
parsec keys export --state-dir /var/lib/parsec -o snapshot.json
# from a Redis-backed ring
parsec keys export --redis-addr redis://localhost:6379 \
--redis-key-prefix parsec-us-east -o snapshot.json
Emits the wire-stable auth.Snapshot JSON. The output of export is
the input of import — operators in different regions shuttle the file
to share signing keys without a live RPC route between them.
Source flags --state-dir and --redis-addr are mutually exclusive;
exactly one must be set. Output goes to stdout unless --out is given.
keys import
parsec keys import --state-dir /var/lib/parsec snapshot.json
parsec keys import --redis-addr redis://localhost:6379 \
--redis-key-prefix parsec-eu-west snapshot.json
Reads a snapshot file and merges it into the local store. Existing non-retired kids in the local ring are preserved; matching kids are overwritten with the snapshot’s role. The active key in the snapshot becomes the active key locally; if multiple actives would result, the import aborts with a coded error so the operator can resolve the conflict.
After import, run parsec keys reload (or send SIGHUP) on every node so
in-process verifiers pick up the new keys without a restart.
See key rotation for the full rotation runbook and multi-region for cross-region propagation.
parsec dlq
Inspect and operate on the sink dead-letter queue. Every parked item is
a DLQItem carrying the failing sink name, the original message, the
last error, and a retry count. The backend is sinks.MemoryDLQ
(single-node) or sinks.RedisDLQ (Redis Streams, one stream per sink at
parsec:dlq:<sink>).
Flags
| Flag | Env | Default |
|---|---|---|
--server | PARSEC_SERVER | http://localhost:8000 |
--token | PARSEC_TOKEN | (empty — required) |
dlq list
parsec dlq list <sink-name> --limit 50
Returns the newest items first. Each row carries:
| Field | Notes |
|---|---|
id | Opaque DLQ identifier. Stable across replays. |
sink | The sink name (matches the Sinks registry key). |
channel | Channel the publication targeted. |
recipient | Sink-specific recipient JSON (decoded via sinks.RecipientDecoder). |
message | The original parsec.Message. |
last_error | Most recent error string. |
attempts | Number of retries before parking. |
parked_at | RFC3339 timestamp. |
dlq count
parsec dlq count <sink-name>
Returns the total parked count for the named sink. Useful to wire into alerting — non-zero is the operator’s signal to investigate.
dlq discard
parsec dlq discard <id>
Permanently remove an item. The publication is lost; no further delivery attempt is made.
dlq replay
parsec dlq replay <id>
Re-enqueue the item through the original sink’s Retrier. The full
retry policy runs again — if the underlying failure persists, the item
returns to the DLQ with an incremented attempts count.
replay requires the sink to implement sinks.RecipientDecoder so the
opaque recipient JSON can be rebuilt into the typed value. Built-in
sinks (email / slack / webhook) all satisfy this; custom sinks must
implement the interface or replay returns PARSEC_SINK_RECIPIENT_DECODE.
Operator runbook
See docs/src/ops/dlq.md for the failure-mode catalog, retention policy, and how to wire DLQ count into alerting.
parsec login / parsec logout
Authenticate against an OIDC identity provider via the device-code flow
(RFC 8628). The resulting ID token is persisted at
~/.parsec/credentials (mode 0600) and picked up automatically by the
CLI’s --token resolution — every subsequent parsec invocation
presents it as the mgmt bearer.
The server side of the bridge is documented at
OIDC bridge. The IdP must implement
device_authorization_endpoint for this flow to work; Google, Okta,
Keycloak, Auth0, and Azure AD all do.
login oidc
parsec login oidc \
--issuer https://accounts.google.com \
--client-id "<your-oauth-client-id>"
| Flag | Env | Default |
|---|---|---|
--issuer | PARSEC_OIDC_ISSUER | (required) |
--client-id | PARSEC_OIDC_CLIENT_ID | (required) |
--client-secret | PARSEC_OIDC_CLIENT_SECRET | (empty — public clients) |
--scope | — | openid email profile groups |
--timeout | — | 5m |
The flow:
- Parsec fetches the issuer’s
/.well-known/openid-configurationand extractsdevice_authorization_endpoint+token_endpoint. - POSTs to the device-auth endpoint with the client id; receives a
user_code, averification_uri, and a polling interval. - Prints the URI + code to stdout. Open the URL in a browser, enter the code, and approve the request.
- Polls the token endpoint at the IdP-advertised interval until the
operator approves, the device code expires, or
--timeoutelapses. - Writes the resulting ID token to
~/.parsec/credentials.
The CLI verifies the token against the IdP’s JWKS as a sanity check. The server re-verifies on every RPC, so a tampered local file just results in 401s.
logout
parsec logout
Removes ~/.parsec/credentials. Subsequent CLI invocations require
--token (or PARSEC_TOKEN) to authenticate again.
Combining with --token
The explicit --token flag and the PARSEC_TOKEN env var take
precedence over the persisted credential. Use this when an operator
needs to temporarily switch identities without logout-ing:
PARSEC_TOKEN="$break-glass-bearer" parsec keys retire <kid>
parsec-gen
parsec-gen reads a schema-registry snapshot and emits typed Go and
TypeScript bindings for every registered channel pattern.
It is a separate binary from parsec; build it with go build ./cmd/parsec-gen or vendor the published release artifact.
Usage
# Go bindings from a running registry into ./gen/parsecgen.
parsec-gen --registry http://localhost:8000/parsec/schemas \
--lang go --out ./gen/parsecgen \
--package parsecgen
# Both languages from a committed snapshot file.
parsec-gen --registry-file ./schemas.json \
--lang go,ts --out ./gen
Flags
| Flag | Default | Purpose |
|---|---|---|
--registry <url> | required if no --registry-file | Schema-registry HTTP endpoint |
--registry-file <path> | required if no --registry | JSON snapshot on disk |
--out <dir> | ./gen | Output directory (created if missing) |
--lang <list> | go | Comma-separated targets: go, ts |
--package <name> | parsecgen | Go package name (ignored for non-Go) |
--header <text> | empty | Optional header prepended to every emitted file |
--registry and --registry-file are mutually exclusive.
Output
| Language | File | Format |
|---|---|---|
go | <out>/parsec_gen.go | gofmt-formatted package |
ts | <out>/index.ts | No external imports |
For each ChannelPattern the generator emits:
- A
<Name>Patternconstant carrying the raw registry pattern. - A
<Name>Channel(...)helper (only if the pattern has placeholders) that reconstructs a concrete channel name from typed arguments. Trailing**placeholders becomerest ...stringin Go andrest: string[]in TypeScript. - One
<Name><Aspect>type per declared aspect. The type follows the declaredpayload_schema:objectbecomes a struct/interface,arraybecomes an alias, scalars become a typed alias.
Naming
Pattern segments contribute to the type name in PascalCase; placeholder segments are dropped from the name and surface only in helper signatures. Aspect names are PascalCased and appended.
| Pattern | Aspect | Generated type |
|---|---|---|
sessions:{id} | data | SessionsData |
brands:meridian.reports | summary | BrandsMeridianReportsSummary |
events.** | raw | EventsRaw |
Common initialisms (id, url, api, http, …) are uppercased in
their entirety so the output passes staticcheck ST1003.
Wiring with the client
The Go output drops straight into client.OnAspectTyped[T any]:
sub, _ := pc.Subscribe(channel)
client.OnAspectTyped[parsecgen.SessionsData](sub, "data",
func(d parsecgen.SessionsData, env envelope.Envelope) {
fmt.Println(d.Text)
})
The TypeScript output drops straight into parsec-client’s
typedSubscribe<T> ergonomic:
import { sessionsChannel, type SessionsData } from "./gen/index";
const sub = client.typedSubscribe<SessionsData>(sessionsChannel(userId));
sub.on("publication", ({ data }) => {
// data: SessionsData
});
await sub.subscribe();
See JavaScript Client (parsec-client) for the full integration.
Regeneration
The generator is deterministic — patterns and aspects are emitted in lexicographic order. Re-running with the same registry yields a byte-identical file. Commit the output and re-run in CI to detect unintended schema drift.
Go API overview
Parsec’s primary deliverable is a Go library. The CLI and Twirp
surfaces are translators on top of the same *parsec.Parsec value.
package main
import (
"context"
"log"
"time"
"github.com/frankbardon/parsec"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p, err := parsec.New(parsec.Options{StateDir: "/var/lib/parsec"})
if err != nil { log.Fatal(err) }
go func() { _ = p.Run(ctx) }()
ch, _ := p.OpenPublic("public:webapp.system.status", time.Hour)
_, _ = p.Publish(ctx, ch.Name.String(), []byte(`{"msg":"hello"}`))
}
The package import path is github.com/frankbardon/parsec. Everything
else (auth, broker, channels, sinks) lives one directory down.
Construction
parsec.New(opts) builds the instance but does not start anything. It
assembles the keyring, the signer/verifier/issuer triad, the channel
manager, and the broker — and wires them together so a closed channel
rejects subscribes before the bridge runs. Every field on Options has
a default; see parsec.New & Options for the per-field
walkthrough.
Lifecycle
The instance is inert until you call Run(ctx):
go func() {
if err := p.Run(ctx); err != nil {
log.Printf("parsec exited: %v", err)
}
}()
Run launches three goroutines and then blocks on the Centrifuge node
inside the same call:
- The sweep loop (
Manager.RunSweeper) that ticks everySweepInterval. - The manager-to-broker event bridge that translates lifecycle events into broker actions (close = no kick; deleted = kick everyone).
- The keyring file watcher (when
StateDiris set) that hot-reloads the ring on mtime changes.
Cancel the context to shut everything down. The broker drains over a
5-second grace window before Run returns.
Surfaces and the embedded model
parsec.Parsec exposes the building blocks for an embedder to mount
its own surfaces:
| Accessor | Returns |
|---|---|
p.Broker() | The Centrifuge wrapper — mount /connection/websocket from this. |
p.Manager() | The channel lifecycle manager — subscribe to events. |
p.Sinks() | The sink registry — register custom sinks. |
p.Verifier() | Token verifier — use for custom subscribe auth. |
p.Issuer() | Token issuer — mint access / refresh / mgmt tokens. |
p.KeyRing() | The HMAC ring — inspect, never mutate directly. |
The standard server in internal/server/ shows how the CLI’s serve
wires Twirp, websocket, SSE, and healthz onto these accessors. You can
reproduce that wiring inside your own HTTP mux, or you can call
parsec.Parsec methods directly from a non-HTTP context (e.g. a
worker that just publishes).
Publishing
Two flavors:
// Always publishes; if nobody is listening, the message is buffered in
// Centrifuge's history but no out-of-band delivery happens.
res, err := p.Publish(ctx, "private:webapp.user.42.downloads",
[]byte(`{"status":"ready"}`))
// If presence == 0, falls back to the named sink. If presence > 0,
// publishes normally and returns delivered=false (since no sink ran).
delivered, err := p.PublishOrSink(ctx,
"private:webapp.user.42.downloads",
[]byte(`{"status":"ready"}`),
"email",
email.Recipient{Address: "user@example.com"},
parsec.Message{Subject: "Your download is ready", Body: "..."})
See custom sinks for writing your own.
Private channels
p.CreatePrivate(subject, name, ttl) mints a record AND issues the
token pair in one call:
creds, err := p.CreatePrivate("user-42",
"private:webapp.user.42.downloads",
30 * time.Minute)
// creds.AccessToken, creds.RefreshToken, expiries
The library is the only place the tokens exist after this call. Don’t log them.
Where to read next
- parsec.New & Options — every option, default, and intent.
- Custom sinks — the
Sinkinterface and registry. - Architecture overview — how the pieces talk to each other.
parsec.New and Options
parsec.New(Options{}) is the only constructor. Every field on
Options is optional and falls back to a documented default.
p, err := parsec.New(parsec.Options{
StateDir: "/var/lib/parsec",
AccessTokenTTL: 5 * time.Minute,
MaxRefreshTokenTTL: time.Hour,
SweepInterval: 30 * time.Second,
})
The full struct is in parsec.go. Each field below corresponds to one
struct member, in declaration order.
FS afero.Fs
The filesystem used by anything in the library that touches disk —
chiefly the keyring loader. Defaults to afero.NewOsFs(). Override in
tests with afero.NewMemMapFs() to keep the keyring fully in memory.
Library code never calls os.Open directly; every disk access goes
through this handle.
Sinks *sinks.Registry
The out-of-band delivery registry. Defaults to an empty registry
(sinks.NewRegistry()); register sinks before calling Run if you
intend to use PublishOrSink. See custom sinks.
KeyRing *auth.KeyRing
The HMAC signing ring. If you provide one, parsec.New uses it as-is
and ignores StateDir. Most embedders leave this nil and pass
StateDir instead — fewer ways to get rotation wrong.
StateDir string
When set, makes the keyring file-backed at
<StateDir>/keyring.json (mode 0600, parent 0700). Bootstrap is
automatic: if the file doesn’t exist, the library generates an initial
active key and writes it. Without this option AND without KeyRing,
the library logs a loud warning and uses an ephemeral ring — tokens do
not survive a restart.
KeyringPollInterval time.Duration
Mtime-poll interval for the keyring file watcher. Default 5 seconds.
The watcher is what makes out-of-band keyring.json edits visible to
a running server. Set to 0 to disable polling — you can still trigger
a reload with SIGHUP or Parsec.ReloadKeys().
AccessTokenTTL time.Duration
Overrides the default access-token lifetime (5 minutes). The issuer
clamps to [1m, 1h], so values outside that range are rejected at
issuance time. Shorter access TTLs mean more refreshes — pick based on
how aggressively you need revocation.
MaxRefreshTokenTTL time.Duration
Overrides the default refresh-token cap (1 hour). The actual refresh
TTL is min(channel.TTL, MaxRefreshTokenTTL) — you cannot mint a
refresh token that outlives its channel.
BrokerOptions broker.Options
Forwarded verbatim to broker.New. The fields that matter most:
HistorySize— per-channel history bound (default 100).PublicHistoryTTL/PrivateHistoryTTL— history retention windows (both default 5m).LogHandler— Centrifuge-level log sink.SubscribeAuthorizer—parsec.NewREPLACES whatever you put here with one that combinesManager.IsOpenand the token-based authorizer. Set this onbroker.Optionsonly if you are wiring the broker withoutparsec.New.
SweepInterval time.Duration
How often the channel manager runs its expiry sweep. Default 30
seconds. Shorter intervals make TTL enforcement more responsive at the
cost of cycles; longer intervals delay both closed (public) and
deleted (private) transitions.
Logger *slog.Logger
Receives boot warnings, keyring reload events, and other operational
messages. Defaults to slog.Default(). Inject your own logger if you
want a structured output stream — every message carries field
attributes (active_key_id, path, etc.) suitable for JSON sinks.
Default summary
| Field | Default |
|---|---|
FS | afero.NewOsFs() |
Sinks | empty sinks.Registry |
KeyRing | bootstrapped from StateDir (or ephemeral if unset) |
StateDir | “” (ephemeral keyring) |
KeyringPollInterval | 5s |
AccessTokenTTL | 5m |
MaxRefreshTokenTTL | 1h |
BrokerOptions.HistorySize | 100 |
BrokerOptions.{Public,Private}HistoryTTL | 5m |
SweepInterval | 30s |
Logger | slog.Default() |
See also
- Go API overview — putting it all together.
- Custom sinks — populate the registry.
- Key rotation — what
StateDiractually buys you.
Custom sinks
A “sink” is what Parsec delivers a message to when the realtime channel
has nobody listening. The sinks.Sink interface is two methods; the
registry is a map[string]Sink.
type Sink interface {
Name() string
Send(ctx context.Context, to Recipient, m Message) error
}
Ship a custom sink by implementing the interface, registering it on
*parsec.Parsec, and naming it in your PublishOrSink calls.
The interface
sinks/sink.go:
type Message struct {
Subject string
Body string
Metadata map[string]string
}
type Recipient any // typed per-sink
type Sink interface {
Name() string
Send(ctx context.Context, to Recipient, m Message) error
}
Recipient is any because each sink defines its own target shape:
- Email sinks expect a struct with an address.
- Slack sinks usually ignore the recipient (the webhook URL fixes the destination).
- Webhook sinks may carry a per-call URL override.
Pick a typed Recipient struct in your sink package and do the
type-assert inside Send. Reference: sinks/email, sinks/slack,
sinks/webhook.
Implementation skeleton
package mysink
import (
"context"
"github.com/frankbardon/parsec/errors"
"github.com/frankbardon/parsec/sinks"
)
type Config struct{ /* ... */ }
type Recipient struct {
UserID string
}
type Sink struct{ cfg Config }
func New(cfg Config) (*Sink, error) {
if cfg == (Config{}) {
return nil, errors.New(errors.SinkConfig, "mysink requires a Config")
}
return &Sink{cfg: cfg}, nil
}
func (s *Sink) Name() string { return "mysink" }
func (s *Sink) Send(ctx context.Context, to sinks.Recipient, m sinks.Message) error {
r, ok := to.(Recipient)
if !ok {
return errors.New(errors.InvalidArgument, "mysink expects mysink.Recipient")
}
if r.UserID == "" {
return errors.New(errors.InvalidArgument, "mysink recipient UserID is empty")
}
// ...deliver...
return nil
}
Two things to imitate from the reference sinks:
- Validate the config in
New; returnerrors.SinkConfig. - Wrap transport failures with
errors.SinkUnavailableso the caller can branch on the code rather than parsing strings.
Registration
Pass a *sinks.Registry into parsec.Options or register on the live
instance — both work.
reg := sinks.NewRegistry()
reg.Register(must(mysink.New(mysink.Config{...})))
p, _ := parsec.New(parsec.Options{Sinks: reg})
// Or, post-construction:
p.Sinks().Register(must(mysink.New(mysink.Config{...})))
Last-write-wins: registering a different sink under the same Name()
silently replaces the previous one. Names should be stable string
literals, not derived at runtime.
PublishOrSink semantics
The fallback rule:
delivered, err := p.PublishOrSink(ctx, name, data, "mysink", to, msg)
- Parse the channel name. Invalid → return the coded error.
- Ask the broker for the presence count (number of live subscribers).
- If
> 0: publish to the broker, touch the channel, returndelivered=false. - If
== 0: look up the sink by name, callSendthrough the retry-wrapped sink. Returndelivered=trueon success, on DLQ landing, or the sink’s error if the DLQ push itself failed.
delivered=true means “the sink path ran” — not “the recipient saw the
message”. With phase-12 retry + DLQ wiring, a transient sink failure
retries up to Options.SinkRetry.MaxAttempts with exponential backoff
before being recorded in the DLQ.
Retry contract
Every sink in the registry is wrapped in a sinks.Retrier at
construction time. The wrapper:
- Calls
Sendonce. On success, returnsnil. - On a transient error (network blip, HTTP 5xx, 429, SMTP 4xx),
waits
BaseBackoff * 2^(attempt-2)(capped atMaxBackoff) with +/-JitterFractionjitter and retries. - On a terminal error (invalid recipient, HTTP 4xx other than 429, config error), aborts the loop immediately.
- When the budget is exhausted, pushes the failed payload + the last
error into the DLQ and returns
nilto the caller. The failure is now recorded; the caller does not see the error. - The DLQ push itself can fail (e.g. Redis unreachable). In that case
the caller gets a
PARSEC_SINK_DLQ_OVERFLOWerror.
Classification
Sinks signal whether an error is transient by wrapping it with
sinks.Transient(err) or sinks.Terminal(err). Custom sinks should
follow the same pattern. The default classifier (sinks.IsTransient)
falls back to a heuristic for unwrapped errors:
context.DeadlineExceeded,context.Canceled→ terminalnet.OpError,net.Error.Timeout(),*url.Error→ transient- HTTP 5xx + 429 → transient (matched on error-message prefix)
- SMTP 4xx → transient
- Everything else → terminal
DLQ wiring
| Backend | Activation |
|---|---|
| Memory DLQ | Default when Options.RedisClient == nil |
| Redis DLQ | Default when Options.RedisClient != nil |
| Custom DLQ | Set Options.DLQ explicitly |
A RecipientDecoder capability on the sink lets the Redis DLQ
round-trip the typed Recipient back into Go on Replay. The reference
email and webhook sinks implement it; slack does not need to because
the channel is fixed by the webhook URL.
See ops/dlq.md for the operator-side runbook.
Manifest exposure
Registered sink names show up in the descriptor envelope under
payload.sinks. The CLI’s --json flag exposes this. Clients can
probe which delivery channels are configured without reading your boot
code.
See also
- Library overview.
- Built-in sinks: email, slack, webhook.
Testing with parsectest
github.com/frankbardon/parsec/parsectest ships test helpers that
mirror net/http/httptest: one-call constructors return a ready
*parsec.Parsec, and teardown is registered via testing.TB.Cleanup.
The package lives in this repository — import "github.com/frankbardon/parsec/parsectest" and you have everything.
Quickstart
package mypkg_test
import (
"context"
"testing"
"time"
"github.com/frankbardon/parsec/parsectest"
)
func TestMyPublisher(t *testing.T) {
p := parsectest.New(t) // ephemeral keyring, in-memory everything
ch, err := p.OpenPublic("public:webapp.system.status", time.Minute)
if err != nil {
t.Fatal(err)
}
if _, err := p.Publish(context.Background(), ch.Name.String(), []byte(`{"k":"v"}`)); err != nil {
t.Fatal(err)
}
}
parsectest.New(t) returns an *Instance that embeds *parsec.Parsec
— every library method is directly available. The broker is fully
booted before the constructor returns; the first Publish is safe.
HTTP integration tests
NewServer mounts the parsec HTTP surface (Twirp + websocket + manifest
- healthz) on a
*httptest.Server:
func TestMyClient(t *testing.T) {
inst := parsectest.NewServer(t)
bearer := inst.MintMgmt(t, "ops", time.Hour)
req, _ := http.NewRequest("GET", inst.BaseURL+"/manifest", nil)
req.Header.Set("Authorization", "Bearer "+bearer)
resp, _ := http.DefaultClient.Do(req)
// ...
}
inst.BaseURL is the *httptest.Server.URL. The bearer middleware is
wired through the parsec verifier, so tokens from MintMgmt or
MintAccess authenticate normally.
Multi-node code paths (miniredis)
NewWithRedis starts an in-process miniredis
and points parsec at it. The broker, channel registry, keyring, DLQ,
and rate limiter all switch to their Redis backends — same code paths
production runs, no docker required:
func TestClusterScenario(t *testing.T) {
inst := parsectest.NewWithRedis(t)
// inst now backs every subsystem with miniredis.
// Spin up a second instance with the same WithRedis option to
// simulate two nodes sharing state.
}
For tests that also want the HTTP surface:
inst := parsectest.NewServerWithRedis(t)
Minting tokens
| Method | Returns |
|---|---|
MintMgmt(t, subject, ttl) | Mgmt bearer (Authorization: Bearer) |
MintAccess(t, subject, channel, ttl) | Access token (websocket connect / private subscribe) |
MintRefresh(t, subject, channel, ttl) | Refresh token for the RefreshToken RPC |
MintPair(t, subject, channel, ttl) | auth.PairResult with both halves + expiries |
All helpers t.Fatal on issuance error — tests do not need to check.
Options
The constructors take variadic parsectest.Option values:
inst := parsectest.NewServer(t,
parsectest.WithLogger(slog.Default()),
parsectest.WithSink(myFakeSink),
parsectest.WithRateLimits(ratelimit.RateLimits{
Publish: ratelimit.Limit{Rate: 10, Per: time.Second},
}),
)
| Option | Purpose |
|---|---|
WithLogger(*slog.Logger) | Replace the default discard logger |
WithStateDir(dir) | Override the keyring location (rarely useful) |
WithSink(sinks.Sink) | Register a sink before retry/DLQ wrapping |
WithRateLimits(rl) | Attach per-bucket rate limits |
WithRedis(client) | Use a pre-built go-redis client (alternative to NewWithRedis) |
WithOptions(fn) | Last-resort hook — receives *parsec.Options for any setting not surfaced above |
What parsectest.New does
- Builds
parsec.OptionswithStateDir: t.TempDir()and a discard logger. - Applies your options on top.
- Calls
parsec.Newand launchesRunin a goroutine. - Polls until
Broker().Started()reports ready (5 s budget). - Registers a
t.Cleanupthat cancels the run context, closes thehttptest.Serverif one was created, and waits up to 5 s for Run to exit.
The result is a fully-running parsec instance, with no manual teardown in your test body.
Schema Registry Broadcast
The schema registry exposes two surfaces: an HTTP endpoint at
/parsec/schemas for snapshot fetches, and a parsec channel onto which
every registry mutation is published in real time. Subscribers join
the channel and hot-reload definitions as patterns are registered,
updated, or deregistered — no polling.
This page covers the broadcast surface. The HTTP handler is documented in Architecture Overview.
Channel
The default broadcast channel is:
public:parsec.registry.schemas
Exposed as schema.ChangeChannel in the library. The channel is
public, so any client carrying a subscribe authorizer that allows
public channels can listen — typically every client.
Operators that want a different channel name pass it via
schema.PublisherOptions.Channel. The wire shape is unchanged.
Wire shape
Each publication is a JSON-encoded schema.Change:
{
"kind": "registered",
"pattern": "sessions:{id}",
"schema": {
"pattern": "sessions:{id}",
"version": 1,
"aspects": { "data": { "name": "data", "payload_schema": { "type": "object" } } }
},
"at": "2026-06-05T20:14:30.123Z"
}
kind is one of registered, updated, or deregistered. The
schema field is omitted on deregistered (the pattern is gone — the
client deletes the cache entry).
Wiring
The schema.Publisher bridges any schema.Registry to any
schema.PublishFunc. Wire it once at boot, alongside the HTTP
handler:
reg := schema.NewMemoryRegistry()
pc, _ := parsec.New(parsec.Options{
SchemaHandler: schema.Handler(reg),
// ...
})
pub := schema.NewPublisher(reg, func(ctx context.Context, ch string, data []byte) error {
_, err := pc.Publish(ctx, ch, data)
return err
}, schema.PublisherOptions{
EnsureChannel: func(ch string) error {
_, err := pc.OpenPublic(ch, time.Hour)
return err
},
})
go func() { _ = pub.Run(ctx) }()
EnsureChannel is invoked exactly once at Run start. Use it to open
the broadcast channel on the broker before the first publication —
parsec rejects publishes to channels that have not been opened.
The Publisher is best-effort: marshal or publish errors are logged at WARN and the loop continues. Subscribers that need exactly-once semantics re-sync against the HTTP snapshot whenever they detect a gap.
Client behavior
A client library following the recommended pattern:
- Fetches
GET /parsec/schemason startup, populates a local cache. - Subscribes to
public:parsec.registry.schemas. - For each
Changeenvelope:registered/updated— replace the cache entry.deregistered— drop the cache entry; pending publications to the removed pattern fail-closed at the validation layer.
- On a transport reconnect, re-fetch the snapshot. The broadcast is not durable across long disconnects.
Cardinality
The broadcast channel is a single channel name, shared by every subscriber. The publisher fans out via the normal Centrifuge broker path — no per-subscriber state in the registry itself.
Twirp service
Parsec speaks Twirp v8 JSON over HTTP. The CLI is a thin wrapper around
this RPC; anything you can do from parsec channels you can also do
with a curl.
curl -X POST https://parsec.example.com/twirp/parsec.ParsecService/ListChannels \
-H "Authorization: Bearer $PARSEC_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
Wire format
- Encoding: Twirp v8 JSON (not protobuf binary). Every request and response body is JSON.
- Path prefix:
/twirp/parsec.ParsecService/. - Method names follow the
service.protodefinition; the full RPC URL is the prefix plus the method name (e.g./twirp/parsec.ParsecService/Publish). - Headers:
Content-Type: application/json,Authorization: Bearer <mgmt-token>(for non-public methods). - Errors are coded — see Errors below.
Auth
Every method except Manifest and RefreshToken requires a valid
mgmt bearer in the Authorization header. The bearer is verified by
auth.Verifier against the live keyring; tokens signed by a retired
key fail as if the signature were bad.
The public methods are:
var PublicMethods = map[string]bool{
MethodManifest: true,
MethodRefreshToken: true,
}
Manifest is public because it is the discovery endpoint.
RefreshToken is public because it validates its own (in-body)
refresh token instead of a bearer.
Methods
The full method list is in rpc/service.proto and mirrored in
rpc/types.go. Grouped by intent:
Discovery
| Method | Request | Response |
|---|---|---|
Manifest | Empty | JSONResponse (descriptor envelope) |
Channels
| Method | Request | Response |
|---|---|---|
OpenPublic | OpenPublicRequest | ChannelResponse |
CreatePrivate | CreatePrivateRequest | Credentials |
ListChannels | Empty | ListChannelsResponse |
GetChannel | ChannelRef | ChannelResponse |
DeleteChannel | ChannelRef | Empty |
Tokens
| Method | Request | Response |
|---|---|---|
RefreshToken | RefreshTokenRequest | RefreshTokenResponse |
IssueMgmt | IssueMgmtRequest | IssueMgmtResponse |
Keys
| Method | Request | Response |
|---|---|---|
ListKeys | Empty | ListKeysResponse |
GenerateKey | Empty | KeySummary |
PromoteKey | KeyRef | Empty |
RetireKey | KeyRef | Empty |
ReloadKeys | Empty | Empty |
Publish / presence
| Method | Request | Response |
|---|---|---|
Publish | PublishRequest | PublishResponse |
Presence | ChannelRef | PresenceResponse |
The request and response shapes are declared in rpc/types.go. The
proto definition lives in rpc/service.proto; running make proto
will (eventually) regenerate service.pb.go and service.twirp.go
from the proto. Until that runs, the hand-rolled types are the
contract.
Errors
Coded errors cross the boundary as Twirp errors. The mapping is one
function — rpc/server.go:writeServiceError. The Twirp code carries
the HTTP status; the response body carries the Parsec code as a
meta.code entry. The full code list:
PARSEC_CHANNEL_INVALIDPARSEC_CHANNEL_NOT_FOUNDPARSEC_CHANNEL_EXISTSPARSEC_CHANNEL_CLOSEDPARSEC_CHANNEL_TTL_EXCEEDS_MAXPARSEC_AUTH_DENIEDPARSEC_AUTH_EXPIREDPARSEC_BROKER_NOT_READYPARSEC_BROKER_INTERNALPARSEC_SINK_UNAVAILABLEPARSEC_SINK_CONFIG_INVALIDPARSEC_INVALID_ARGUMENTPARSEC_INTERNAL
See Troubleshooting for what to do when they fire.
Client
The reference Go client is in rpc/client.go. The CLI uses it
through internal/rpcclient/. Both are non-stable packages — if you
build an external Go client, generate one from the proto with
make proto once that target is wired.
See also
parsec channels— the CLI surface of the same calls.- Architecture — where the RPC server sits in the request flow.
Email sink
A thin SMTP relay. Delivers the Message via net/smtp.SendMail.
import (
"github.com/frankbardon/parsec"
"github.com/frankbardon/parsec/sinks/email"
)
es, _ := email.New(email.Config{
Addr: "smtp.example.com:587",
From: "parsec@example.com",
Username: "parsec",
Password: os.Getenv("SMTP_PASSWORD"),
})
p, _ := parsec.New(parsec.Options{})
p.Sinks().Register(es)
Once registered, you reference it by name in PublishOrSink:
delivered, err := p.PublishOrSink(ctx,
"private:webapp.user.42.downloads",
[]byte(`{"status":"ready"}`),
"email",
email.Recipient{Address: "user@example.com"},
parsec.Message{
Subject: "Your download is ready",
Body: "Tap the link in the app.",
})
Configuration
email.Config:
| Field | Required | Notes |
|---|---|---|
Addr | yes | SMTP host:port — e.g. smtp.example.com:587. |
From | yes | RFC 5322 mailbox-list, used as the envelope sender. |
Username | no | Omit for relays without auth (e.g. local Postfix). |
Password | no | Required when Username is set. |
If Username is set the sink uses smtp.PlainAuth over the
connection. If not, the sink connects unauthenticated. The host
component is parsed from Addr and used as the PlainAuth host
identity.
Recipient
email.Recipient:
type Recipient struct {
Address string
}
Address is required and must be an RFC 5322 mailbox. Empty addresses
return PARSEC_INVALID_ARGUMENT.
Body shape
The sink stitches together a minimal RFC 5322 message:
From: <cfg.From>
To: <recipient.Address>
Subject: <message.Subject>
<message.Body>
Nothing else. No HTML wrapping, no MIME alternative, no templating —
the publisher is responsible for whatever content shape the recipient
expects. If you need richer email (HTML bodies, attachments, custom
headers from Metadata), wrap this sink with your own and pre-format
the body before calling Send.
Failure semantics
| Condition | Returned code |
|---|---|
Missing Addr or From at construction | PARSEC_SINK_CONFIG_INVALID |
| Wrong recipient type | PARSEC_INVALID_ARGUMENT |
| Empty recipient address | PARSEC_INVALID_ARGUMENT |
smtp.SendMail returns error | PARSEC_SINK_UNAVAILABLE |
The sink does NOT retry. Network blips return immediately. If you need
durability, wrap the sink in your own retry layer or queue the message
before calling PublishOrSink.
See also
- Custom sinks — write your own.
- Slack sink and Webhook sink — the other reference implementations.
Slack sink
Posts to a Slack incoming-webhook URL. One sink instance per destination channel; the webhook URL fixes which Slack channel the message lands in.
import (
"github.com/frankbardon/parsec"
"github.com/frankbardon/parsec/sinks/slack"
)
s, _ := slack.New(slack.Config{
WebhookURL: os.Getenv("PARSEC_SLACK_WEBHOOK"),
})
p, _ := parsec.New(parsec.Options{})
p.Sinks().Register(s)
Then:
_, _ = p.PublishOrSink(ctx,
"private:agent.analysis.42.failures",
[]byte(`{"job":42,"err":"timeout"}`),
"slack",
nil, // recipient is ignored
parsec.Message{
Subject: "Job 42 failed",
Body: "timeout while fetching artifacts",
})
Configuration
slack.Config:
| Field | Required | Notes |
|---|---|---|
WebhookURL | yes | The full Slack incoming-webhook URL. |
HTTPClient | no | Defaults to http.DefaultClient. Override to inject timeouts / tracing. |
Recipient
slack.Recipient is an empty struct — the channel is determined by the
webhook URL itself. Callers may pass nil to Send; the sink ignores
the value.
If you need to post to multiple Slack channels, register multiple sink
instances under different names (e.g. "slack-alerts",
"slack-deploys") and pick the right one per call.
Payload shape
The sink posts a JSON body like:
{
"text": "*Job 42 failed*\ntimeout while fetching artifacts",
"metadata": {"trace_id": "..."}
}
Subject becomes a bold prefix on text; an empty Subject posts the
body alone. Metadata is attached verbatim so it appears in Slack
webhook metadata where supported.
Failure semantics
| Condition | Returned code |
|---|---|
Missing WebhookURL at construction | PARSEC_SINK_CONFIG_INVALID |
http.Client.Do returns an error | PARSEC_SINK_UNAVAILABLE |
| Slack returns a >= 300 status | PARSEC_SINK_UNAVAILABLE (carries the status line) |
The sink does not retry. Slack rate-limits webhooks; downstream
clients that hit 429 should back off — the sink does not.
Common gotchas
- Slack webhooks are long-lived but per-channel. If you change the destination Slack channel, mint a new webhook and re-register.
- Slack does not signal whether the human at the keyboard saw the
message —
Sendreturning nil means “Slack accepted the post”, not “the user saw it”. Real delivery confirmation needs the Slack Web API and a different sink.
See also
- Custom sinks — write your own.
- Email sink and Webhook sink — the other reference implementations.
Webhook sink
Posts a JSON envelope to a configurable HTTP endpoint. Use this when the downstream consumer is another service rather than a person — internal scoreboards, downstream workers, audit sinks.
import (
"github.com/frankbardon/parsec"
"github.com/frankbardon/parsec/sinks/webhook"
)
w, _ := webhook.New(webhook.Config{
URL: "https://hooks.internal.example.com/parsec",
Headers: map[string]string{
"X-Parsec-Source": "prod",
"Authorization": "Bearer " + os.Getenv("DOWNSTREAM_TOKEN"),
},
})
p, _ := parsec.New(parsec.Options{})
p.Sinks().Register(w)
Then:
_, _ = p.PublishOrSink(ctx,
"private:webapp.user.42.downloads",
[]byte(`{"status":"ready"}`),
"webhook",
webhook.Recipient{URL: ""}, // use the configured URL
parsec.Message{
Subject: "download.ready",
Body: `{"user_id":42}`,
Metadata: map[string]string{"trace_id": "abc"},
})
Configuration
webhook.Config:
| Field | Required | Notes |
|---|---|---|
URL | yes | Default POST target. Used when the recipient does not override it. |
Headers | no | Static headers added to every request. Useful for an auth header or a fixed X-Parsec-Source. |
HTTPClient | no | Defaults to http.DefaultClient. Inject your own for timeouts, tracing, retries. |
Recipient
webhook.Recipient:
type Recipient struct {
URL string
}
URL == "" (or passing nil / a non-webhook.Recipient value) means
“use the configured URL”. A non-empty URL overrides the destination
on a per-call basis — useful when one sink instance fans out to
multiple downstream services that share a common header set.
Payload shape
The sink marshals a fixed envelope:
{
"subject": "download.ready",
"body": "{\"user_id\":42}",
"metadata": {"trace_id": "abc"}
}
Fields with zero values are omitted. The wire Content-Type is
application/json. Configured headers are set after the
Content-Type, so a config that overrides Content-Type wins.
Failure semantics
| Condition | Returned code |
|---|---|
Missing URL at construction | PARSEC_SINK_CONFIG_INVALID |
| Marshalling the envelope fails | PARSEC_INTERNAL (very rare) |
http.Client.Do returns an error | PARSEC_SINK_UNAVAILABLE |
| Downstream returns >= 300 status | PARSEC_SINK_UNAVAILABLE (carries the status line) |
The sink does NOT retry, NOT queue, and NOT batch. It’s a thin forwarder. If downstream is flaky, wrap it.
Patterns
- One sink per downstream service is the easy default — name them
"webhook-audit","webhook-scoreboard", etc. - One sink + per-call URL override works when the destinations share headers (auth, tracing) but differ in path.
- For at-least-once semantics, queue the message yourself (e.g. in
Kafka or a database table) and have a worker call
Sendwith a retry loop. Parsec sinks are best-effort, not durable.
See also
- Custom sinks — write your own.
- Email sink and Slack sink.
Architecture overview
Parsec is a library with three parallel surfaces (CLI, Twirp,
websocket). Every surface translates an external call into a
service.Service method, which in turn drives the *parsec.Parsec
instance.
flowchart TD
subgraph external [External clients]
CLI[parsec CLI]
HTTP[Twirp HTTP client]
WS[Browser / centrifuge-js]
end
subgraph surfaces [Surfaces]
SVC[service.Service]
RPC[rpc + internal/server]
Hub[centrifuge.Hub]
end
subgraph core [Library core]
P[parsec.Parsec]
B[broker.Broker]
M[channels.Manager]
S[sinks.Registry]
A[auth: KeyRing + Signer + Verifier + Issuer]
end
CLI -- Twirp --> RPC
HTTP -- HTTP --> RPC
RPC --> SVC
SVC --> P
P --> B
P --> M
P --> S
P --> A
B -- mounts --> Hub
WS -- websocket --> Hub
M -- events --> P
P -- bridge --> B
The library is the deliverable
parsec.Parsec is the unit under test. The CLI binary is one
assembler in cmd/parsec/main.go that hands command parsing to
urfave/cli/v3 and dispatches into internal/cli/. The Twirp surface
is a sibling.
Every surface uses the same service.Service shim, which is the
surface-agnostic API. The shim exists so the CLI and Twirp handlers
can share their business logic — and so the library callers
(embedders) can pretend the surfaces don’t exist.
Component map
| Package | Responsibility |
|---|---|
parsec | The library facade: New, Run, Publish, PublishOrSink, CreatePrivate, RefreshAccess, key mutation methods. |
auth | HMAC signer / verifier / issuer; keyring; subscribe authorizer for the broker. |
broker | A thin wrapper over centrifuge.Node. Owns publish, presence, and the subscribe-authorization hook. |
channels | The name grammar (ParseName) and the lifecycle manager (Manager) with TTL sweep. |
sinks | The Sink interface and registry; ships email/slack/webhook reference implementations. |
service | Surface-agnostic verbs the CLI and Twirp servers call into. |
rpc | Twirp-JSON types, server, client, and bearer middleware. |
descriptor | Manifest envelope (CLI --json, /manifest). |
errors | The PARSEC_* coded error system. |
internal/cli | CLI command bodies. |
internal/server | HTTP mux: Twirp + websocket + SSE + healthz. |
cmd/parsec | main.go — assembles the command tree, nothing else. |
Request flow: a publish
- Operator runs
parsec publish public:webapp.system.status -d '{...}'. internal/cli/publish.goparses flags and callsinternal/rpcclient.Publish(...).- The client posts to
/twirp/parsec.ParsecService/Publishwith a mgmt bearer. internal/server/routes through the bearer middleware (auth.Verifier) and intorpc/server.go.- The RPC server calls
service.Service.Publish(...). service.Servicecallsparsec.Parsec.Publish(...).Parsec.Publishparses the name, asserts the channel is known to the manager, and callsbroker.Broker.Publish.- The broker hands off to
centrifuge.Node.Publish, which fans the message out to every subscriber and records it in history.
Request flow: a subscribe
- Browser opens a websocket to
/connection/websocketwith an access token. - The broker’s
OnConnectingextracts the token and resolves the subject. - On
SUBSCRIBE, the broker invokes theSubscribeAuthorizerthatparsec.Newinstalled — which checks both that the channel is open (Manager.IsOpen) AND that the token is valid for that channel (auth.SubscribeAuthorizer). - On success the subscription completes; on failure Centrifuge sends the typed error to the client.
Manager-to-broker bridge
The channel manager emits typed Event values on every lifecycle
transition; parsec.Parsec.runEventBridge translates them:
| Event | Broker action |
|---|---|
EventOpened | Nothing — Manager.IsOpen already returns true. |
EventClosed (public, TTL exceeded) | Nothing — existing subscribers drain. |
EventDeleted | broker.UnsubscribeAll(name) — kick every connected subscriber. |
That asymmetry is the single most important rule in the codebase:
closed != deleted. See TTL and expiry.
See also
Broker (Centrifuge)
broker.Broker is a thin wrapper around *centrifuge.Node. Its job is
to keep Parsec’s other packages from depending directly on the
Centrifuge OSS library and to install the two hooks Parsec actually
cares about: connect and subscribe authorization.
br, err := broker.New(broker.Options{
HistorySize: 100,
PublicHistoryTTL: 5 * time.Minute,
PrivateHistoryTTL: 5 * time.Minute,
SubscribeAuthorizer: myAuthorizer, // optional
})
go br.Run(ctx)
What gets wrapped
*centrifuge.Node is the underlying broker. Parsec exposes a narrow
surface on top:
| Method | Centrifuge equivalent | Notes |
|---|---|---|
New(opts) | centrifuge.New(cfg) | Installs the connect + subscribe hooks. |
Run(ctx) | node.Run() + node.Shutdown | Blocks until ctx is done; 5s shutdown grace. |
Publish(ctx, ch, data) | node.Publish(...) | History size + TTL filled in from Options. |
Presence(ctx, ch) | node.Presence(...) | Returns just the subscriber count. |
UnsubscribeAll(ch) | iterate hub.Connections() | Used by the bridge on EventDeleted. |
Node() | n/a | Escape hatch — the HTTP mount needs it to install the websocket handler. |
Connect hook
OnConnecting is installed at construction time with a permissive
default:
node.OnConnecting(func(ctx context.Context, _ centrifuge.ConnectEvent) (centrifuge.ConnectReply, error) {
return centrifuge.ConnectReply{Credentials: ¢rifuge.Credentials{UserID: ""}}, nil
})
Anonymous connect is intentional. The real authorization happens at
SUBSCRIBE time, because that is when the channel name is known. An
attacker who connects but never subscribes does nothing useful.
Embedders that need per-connection identity (e.g. browser SSO) can
override this hook by calling broker.Node().OnConnecting(...) after
broker.New.
Subscribe hook
The subscribe path is the security boundary. For every subscribe attempt:
channels.ParseName(event.Channel)— invalid grammar → reject.SubscribeAuthorizer(ctx, userID, name, event)— runs the configured authorizer.
parsec.New replaces whatever SubscribeAuthorizer you put on
broker.Options with one that does TWO checks:
if !manager.IsOpen(ch) {
return errors.New(errors.ChannelClosed, "channel not open")
}
return tokenAuth(ctx, userID, ch, event)
So a closed channel rejects new subscribes BEFORE the token is
even examined. The token check (auth.NewSubscribeAuthorizer(verifier))
allows any well-formed public channel and validates the embedded
access token against private channels.
The default authorizer (when you skip parsec.New) is stricter still
— it rejects every private channel outright:
func defaultSubscribeAuthorizer(_ context.Context, _ string, ch channels.Name, _ centrifuge.SubscribeEvent) error {
if ch.IsPrivate() {
return errors.New(errors.AuthDenied, "private channel requires a SubscribeAuthorizer")
}
return nil
}
Use that when you wire broker.New directly and have not yet built a
token authorizer.
Publish path
Publish is a thin layer over node.Publish with history options
chosen by visibility:
node.Publish(ch.String(), data,
centrifuge.WithHistory(opts.HistorySize, b.historyTTLFor(ch)))
Public and private channels can be tuned independently via
PublicHistoryTTL and PrivateHistoryTTL. Defaults are 5 minutes
each.
When the broker is “not ready”
Publish checks an internal started flag and returns
PARSEC_BROKER_NOT_READY if Run has not yet executed
node.Run(). Callers should retry on this code — it usually means the
server is still booting.
Deeper Centrifuge internals
Parsec relies on Centrifuge for the actual transport layer (websocket, SSE, hub fanout, history). The OSS library’s design is well-documented upstream:
- The Centrifuge documentation:
https://centrifugal.dev/docs/server/library_intro - The Go API reference:
https://pkg.go.dev/github.com/centrifugal/centrifuge
Parsec deliberately does NOT re-document those internals. The contract
is “anything the OSS library can do is reachable via Broker.Node()”;
the wrapper exists to keep the rest of Parsec from coupling to
Centrifuge symbols directly.
See also
- Architecture.
- Channel manager.
- Channel naming — the parse step.
Channel manager
channels.Manager owns the lifecycle of every channel the broker has
heard of. Its job is to track (state, ttl, last_active) and emit
events when a transition happens.
m := channels.NewManager()
events := make(chan channels.Event, 64)
m.Subscribe(events)
go m.RunSweeper(ctx, 30*time.Second)
The broker side of the wire effects (kick subscribers on delete, drain
on close) is driven by these events through the manager-to-broker
bridge in parsec.Parsec.runEventBridge.
State
The channel record:
type Channel struct {
Name Name
State State // open | closed | deleted
TTL time.Duration
CreatedAt time.Time
LastActive time.Time
}
Three lifecycle states:
| State | Meaning |
|---|---|
open | Accepting subscribes; publish path is live. |
closed | Public only. Past TTL; no new subscribes but record is kept. |
deleted | Removed from the manager. Subscribers must be kicked. |
The constants live in channels/manager.go:
const MaxPrivateTTL = 1 * time.Hour
const DefaultPublicTTL = 30 * time.Minute
Transitions
OpenPublic ──► state=open EventOpened (on new or reopen)
CreatePrivate ──► state=open EventOpened
Sweep (public, ──► state=closed EventClosed (no kick)
TTL hit)
Sweep (private, ──► record removed EventDeleted (kick)
TTL hit)
Delete ──► state=deleted EventDeleted (kick)
Touch / Publish ──► last_active = now (no event)
The state=open → state=open re-open on OpenPublic for an already
open channel does NOT emit a fresh opened event — the contract is
“only on the closed→open edge”.
Sweep behavior
Manager.Sweep(now time.Time) int runs one pass over every channel:
- Skip non-open channels (closed or already-deleted records).
- Skip channels with
now.Sub(LastActive) < TTL. - For private channels past TTL:
delete(m.channels, key)+ emitEventDeleted. - For public channels past TTL: flip to
StateClosed+ emitEventClosed.
The sweep is called by RunSweeper on a ticker, but tests usually
call Sweep(t) directly with a fixed clock — see
channels/manager_test.go.
Event subscription
Consumers register with:
events := make(chan channels.Event, 16)
m.Subscribe(events)
Buffer the channel. The manager does non-blocking sends — a slow consumer drops events. Two consumers are common in a live process:
- The broker bridge (
parsec.Parsec.runEventBridge) translates events into wire actions. - Test code or telemetry hooks listen to the same stream for visibility.
Events are emitted from a goroutine that holds no manager lock; you do not need to worry about re-entering the manager from a subscriber.
Listing
Manager.List() returns a snapshot copy of every record. Deleted
records are excluded from Get but included in List so a tool can
see the tombstone (the state field tells you which is which).
Thread-safety
Manager is safe for concurrent use. Internally it holds two
mutexes — one for the channel map, one for the subscribers slice —
plus a per-channel mutex-free copy-on-read pattern for Get /
List. The hot paths (IsOpen, Touch, Publish-side Touch) take
the read lock and the writer lock briefly, respectively.
Test clock
Manager.SetClock(func() time.Time) swaps the internal time source.
Tests use this to deterministically drive TTL expiry without sleeping:
m.SetClock(func() time.Time { return fixedTime })
m.Sweep(fixedTime.Add(time.Hour))
See channels/manager_test.go for the canonical pattern.
See also
- Public channels.
- Private channels.
- TTL and expiry.
- Broker internals — how the events become wire actions.
Deployment
How to run parsec serve in something resembling production. The
single-node story is the default; the multi-node story attaches a Redis
backend.
parsec serve \
--addr :8000 \
--state-dir /var/lib/parsec \
--keyring-poll 5s
Topology: single node
The simplest deployment is one Parsec process with an in-memory channel registry and a file-backed keyring. Centrifuge holds tens of thousands of concurrent websockets per core — single-node carries a lot of load.
Topology: clustered
Point Options.RedisClient (or the YAML redis.addr field) at a
shared Redis and the broker, channel registry, keyring, DLQ, and rate
limiter all switch to their Redis-backed implementations. Multiple
Parsec nodes then share the same truth about open channels, keys, and
sink failures. See key rotation
for the rotation walkthrough.
Required state
| Path | What | Survives restart? |
|---|---|---|
<state-dir>/keyring.json | HMAC signing keys | Yes |
| In-memory channel map | Open channels, private records | No |
| In-memory subscriber set | Live websocket connections | No |
The manifest reports "persistence": "in-memory" so clients can
discover the stance without reading docs. The contract: a restart wipes
every channel, every subscriber must reconnect, every private channel
must be re-created with fresh tokens.
If that is a problem for your use case, Parsec is the wrong primitive — use a real durable queue.
--state-dir
Always pass it in production. Without --state-dir, the keyring is
ephemeral and every restart mints a new bootstrap token under a brand
new active key. Existing browser clients can no longer refresh and
must re-authenticate. With --state-dir, the ring survives the
restart and active sessions tolerate a quick bounce.
The directory is created with mode 0700; the keyring file with mode
0600. Both are owned by the running uid. Mount your state volume so
the uid is consistent across restarts.
Environment variables
The CLI reads these at boot. The library does not — it takes typed
values via Options.
| Variable | Default | Notes |
|---|---|---|
PARSEC_STATE_DIR | “” | Same as --state-dir. |
PARSEC_MGMT_SUBJECT | operator | Subject claim on the bootstrap mgmt token. |
PARSEC_MGMT_TTL | 24h | Bootstrap mgmt TTL. |
PARSEC_KEYRING_POLL | 5s | mtime-poll interval. 0 disables polling. |
PARSEC_NO_AUTH | unset | Dangerous; disables the bearer middleware. Dev only. |
PARSEC_SERVER | http://localhost:8000 | Default --server for the client subcommands. |
PARSEC_TOKEN | “” | Mgmt bearer for the client subcommands. |
Capturing the bootstrap token
parsec serve prints the bootstrap mgmt token to STDERR exactly once.
A typical systemd unit captures it like this:
[Service]
ExecStart=/usr/local/bin/parsec serve --addr :8000 --state-dir /var/lib/parsec
StandardError=append:/var/log/parsec/boot.log
Then grep the log:
grep "bootstrap mgmt token" /var/log/parsec/boot.log
If you persist the keyring, the same token continues to work across restarts — fetch it once, store it in your secret manager, move on.
Health and observability
| Endpoint | Purpose |
|---|---|
/healthz | Liveness probe. Returns 200 once node.Run has succeeded. |
/manifest | Descriptor envelope — surfaces, sinks, version. |
Centrifuge’s own metrics are exposed under
broker.Node().Metrics(...); mount them on your own /metrics
handler if you want Prometheus.
Upgrades
A restart kills channel state. The supported upgrade procedure:
- Drain or accept the disruption (browsers reconnect; private channels need fresh tokens).
systemctl restart parsec(or your orchestrator’s equivalent).- The new process loads the same
keyring.json; active operator bearers continue to verify.
If you cannot tolerate the disconnect window, queue a maintenance
banner on public:<app>.broadcast.maintenance and run during a
quiet period.
See also
Configuration file
parsec serve --config <path> loads operator settings from a YAML file
so multi-node deployments and version-controlled rollouts have a single
artifact to track. The flag is also reachable via the PARSEC_CONFIG
environment variable.
Precedence
CLI flag > env var > config file > built-in default
Any flag the operator passes explicitly wins over the file. Anything the file doesn’t set falls through to defaults baked into the binary. There is no merging of structured fields — each scalar is settled at one layer.
Format
YAML. Reasons it’s the default:
- Familiar to operators coming from Kubernetes, GitHub Actions, Docker Compose, and most other Go ecosystem libraries
- Anchors / aliases supported if you need them (rarely)
- Comments survive
- Parsed by
gopkg.in/yaml.v3— the de facto Go YAML decoder
Strings support ${ENV_VAR} interpolation so secrets can be sourced from
the environment without inlining them into the file:
observability:
metrics_bearer_token: "${PARSEC_METRICS_TOKEN}"
Unknown keys are rejected at load time — a typo in a key name fails boot with a clear error rather than silently ignoring the setting.
Reference
See examples/config/parsec.yaml in the repo for a fully-populated
configuration with comments explaining each field. The schema follows
the parsec.Options surface; every CLI flag has a matching file field.
Sections
| Section | Purpose |
|---|---|
server | Listen address, debug toggles |
auth | Keyring location, bootstrap mgmt token settings |
redis | Cross-node Redis coherence (channels + keyring + DLQ + rate limits) |
manager | Channel manager tunables (sweep interval) |
sink_retry | Global + per-sink retry policy |
rate_limits.* | Publish / subscribe / token-issue buckets |
observability | Metrics, tracing, access-log trusted proxies |
Mutual exclusions
The loader rejects impossible combinations at startup:
- A
rate_limits.<bucket>withrate > 0must also setper
Caught at boot, not at first request.
Boot diagnostics
When --config is set, parsec serve logs parsec serve: loaded config path=<path> on startup, and the manifest exposes a
config_source field with the same value so clients can confirm which
file is active:
curl -s -H "Authorization: Bearer $PARSEC_TOKEN" \
http://localhost:8000/twirp/parsec.ParsecService/Manifest \
| jq '.payload.config_source'
Single-source-of-truth pattern
In production, prefer:
- Commit a
parsec.yamlper environment (config/staging.yaml,config/prod.yaml) - Reference secrets via
${ENV_VAR}interpolation - Run with
parsec serve --config config/prod.yaml - Override anything urgent via CLI flag — the file stays the long-running baseline; flags handle the incident-response case
See also
examples/config/parsec.yaml— reference file- Deployment — single-node and multi-node patterns
- Rate limiting — bucket semantics
- Observability — metrics, tracing, log fields
Key rotation runbook
This document walks the full procedure for rotating Parsec’s HMAC signing key without dropping a single live subscriber or refresh-flow client.
The contract: tokens minted under any non-retired key continue to verify. Promoting a new key does not invalidate prior tokens. Retiring a key does — only after you have waited for tokens signed by that key to expire naturally.
Preconditions
- Parsec is running with
--state-dir <dir>. Without it the keyring is ephemeral and rotation does nothing useful. - You have a valid mgmt bearer token. Export it as
PARSEC_TOKEN. - You know the longest TTL in use:
- Access tokens: 5m by default
- Refresh tokens: ≤ channel TTL (max 1h)
- Mgmt tokens: ≤ 7d (24h default)
The wait between Promote and Retire is the maximum of these — for a deployment with default TTLs that is 24 hours.
Procedure
export PARSEC_SERVER="https://parsec.internal:8000"
export PARSEC_TOKEN="<current mgmt bearer>"
# 1. Inspect current state. There should be exactly one active key.
parsec keys list
# 2. Generate a new key. It joins the ring as verify-only — it does not
# sign anything yet. You can do this at any time.
NEW_KID=$(parsec keys generate | jq -r '.payload.id')
echo "new key: $NEW_KID"
# 3. Promote the new key. From this moment on every newly-minted token
# embeds the new kid. Tokens already in flight continue to verify
# because the previous active key is now verify-only.
parsec keys promote "$NEW_KID"
# 4. Mint a fresh mgmt bearer signed by the NEW key. Use this bearer
# going forward — your existing bearer is still valid but will die
# when you retire the old key in step 6.
NEW_BEARER=$(parsec tokens mgmt --ttl 24h | jq -r '.payload.mgmt_token')
export PARSEC_TOKEN="$NEW_BEARER"
# 5. Wait. The wait must cover the longest TTL of tokens that may have
# been signed by the previous active key. With Parsec defaults this
# is 24 hours; lower TTL deployments may need less.
sleep 86400 # not literally — schedule this, monitor in the meantime
# 6. Retire the old key. From this moment on tokens claiming the old
# kid fail with PARSEC_AUTH_DENIED.
OLD_KID=$(parsec keys list | jq -r '.payload.keys[] | select(.role=="verify-only") | .id')
parsec keys retire "$OLD_KID"
# 7. Confirm the ring is clean.
parsec keys list
SIGHUP / manual reload
If you edit keyring.json out-of-band (do not do this normally — go
through the CLI), the running server will pick it up either on the next
mtime-poll tick (every 5s by default) or when you signal it:
kill -HUP $(pgrep parsec)
# OR
parsec keys reload
Both achieve the same swap.
Multi-node deployments
Point every Parsec node at a shared Redis (Options.RedisClient or
the YAML redis.addr field). The Redis-backed KeyRingStore
publishes a version event on every rotation, and every node subscribes
— parsec keys generate/promote/retire on any one node propagates to
the rest of the cluster within milliseconds without operator
intervention. The file-backed keyring + NFS pattern still works for
deployments without Redis; in that case disable the mtime poller on
all but one node (--keyring-poll 0) and SIGHUP the others after
each rotation.
What if I have to break glass?
If a key has been compromised and you cannot wait for tokens to expire:
# 1. Generate + promote a new key.
NEW_KID=$(parsec keys generate | jq -r '.payload.id')
parsec keys promote "$NEW_KID"
NEW_BEARER=$(parsec tokens mgmt | jq -r '.payload.mgmt_token')
export PARSEC_TOKEN="$NEW_BEARER"
# 2. Retire the compromised key immediately. Every token signed by it
# fails verification; live subscribers using a compromised access
# token are kicked at next protocol heartbeat.
parsec keys retire <compromised-kid>
This is destructive: every in-flight token signed by that key dies. Browser clients reconnect via the refresh flow only if their refresh token was signed by a DIFFERENT key — otherwise the user is logged out and must re-authenticate through your application’s normal flow.
What never works
- You cannot retire the active key. Promote another key first.
- You cannot resurrect a retired key. The disk snapshot drops retired entries; the in-memory ring drops them on next reload. Mint a new one if you need one.
- You cannot use a key the ring does not know about. Tokens with a
kidclaim that doesn’t resolve to a live key fail as if the signature were bad. Parsec doesn’t tell the attacker which case.
Refresh-Token Rotation
Parsec rotates refresh tokens on every redemption. Each refresh carries a JTI (per-token unique ID) and a FID (rotation-family ID). Successful redemption mints a fresh access and a fresh refresh that shares the old FID and carries a new JTI; the old refresh is marked redeemed in the rotation store and cannot be used again.
A second redemption of the same JTI is treated as a leak: the entire family is revoked, so every refresh in the rotation chain — past, present, and future — fails until the recorded expiry passes.
This page covers the rotation contract, the backing store, the operator-facing knobs, and what to do when a family is revoked.
Why rotation matters
A long-lived refresh token is a credential. If it leaks (shoulder-surf, disk image, log spill) the attacker can mint access tokens until the refresh expires — typically minutes to an hour. Without rotation there is no way to detect that the leak happened: both the legitimate client and the attacker hold the same valid token.
With rotation the legitimate client’s next refresh attempt presents the already-redeemed JTI, the server detects the reuse, and Parsec revokes the family. Sessions break loudly — but only for compromised chains, and the operator gets a metric and an access-log line that name the incident.
Wire shape
The RefreshTokenResponse carries the rotated refresh:
{
"access_token": "...",
"access_expires_unix": 1735300000,
"refresh_token": "...",
"refresh_expires_unix": 1735303600,
"rotated": true
}
Legacy tokens (no JTI on the input refresh) yield a response without
refresh_token / refresh_expires_unix and with rotated: false. A
modern client always uses the returned refresh on the next redemption.
Rotation store
Two records per chain, scoped to the refresh’s natural TTL:
| Record | Purpose |
|---|---|
jti | Marked on successful redemption. A second SET attempt with the same JTI fails — that is the reuse signal. |
fid | Marked on detected reuse. While present, every JTI in the family is rejected without even consulting its own record. |
Two backends:
-
MemoryRefreshStore— single-node; entries live in a map and age out on a periodic pruner (default 5 minutes; tune viaparsec.Options.MemoryRefreshPruneInterval). -
RedisRefreshStore— multi-node; SETNX with a TTL equal to the refresh’s remaining lifetime. Key layout:<prefix>:refresh:jti:<jti> → "1" TTL = exp - now <prefix>:refresh:fid:<fid> → "1" TTL = exp - now
parsec.New builds the right backend automatically: Redis when
RedisClient is configured, in-memory otherwise. Operators can
inject a custom backend by setting Options.RefreshStore.
Operator-facing knobs
| Option | Default | Effect |
|---|---|---|
RefreshStore | nil → auto | Explicit backend |
MemoryRefreshPruneInterval | 5m | How often the in-memory backend reclaims expired records |
MaxRefreshTokenTTL | 1h | Upper bound on every refresh, original or rotated |
AccessTokenTTL still bounds the access half (default 5m, clamped to
[1m, 1h]). Rotation does not extend the access TTL beyond the refresh
expiry.
What to do when a family is revoked
parsec_refresh_rotations_total{result="reused"} is the loud
signal — every increment is a confirmed reuse. The corresponding
client will hit PARSEC_AUTH_DENIED on its next refresh and need a
fresh credential pair from CreatePrivate.
Triage:
- Find the access log line for the failed redemption
(
bearer_subject+request_id). Thesubclaim names the compromised session. - Inspect the channel granted to that session — assume the attacker subscribed for the duration the refresh was valid.
- Issue a new pair via
CreatePrivateif the legitimate client is still online; otherwise let the channel TTL expire naturally.
Family revocation only affects the one rotation chain. Other users and other refresh families for the same subject continue to work.
Backward compatibility
Refresh tokens minted by older Parsec versions have no JTI / FID. The
rotation gate detects this and short-circuits to the legacy path:
mint a fresh access only, never touch the store, no refresh_token
in the response. Clients upgrading to the rotation surface should
re-CreatePrivate once to obtain a JTI-bearing refresh; until they
do, sessions still work but the leak-detection guarantee does not
apply.
Metrics
parsec_refresh_rotations_total{result="rotated"}— successful rotation.parsec_refresh_rotations_total{result="reused"}— reuse detected; family revoked.parsec_refresh_rotations_total{result="family_revoked"}— caller presented a refresh whose family was already revoked.parsec_refresh_rotations_total{result="legacy"}— pre-rotation token; no store interaction.
Cardinality budget: result is one of four constants. No subject or
channel labels.
Asymmetric Signing (RS256 / EdDSA / ES256 / ES384)
Parsec’s default signer is HS256 — a single 32-byte HMAC secret per key. Symmetric is fast, small on the wire, and easy to rotate when the verifier sits in the same process. It does NOT fit deployments where verifying tokens needs to happen somewhere parsec is not (a CDN edge, a separate API gateway, an external partner). Sharing the HMAC secret to those parties would give them the ability to mint tokens too.
This page covers Parsec’s RS256, EdDSA, and ECDSA (ES256, ES384) support: how to add an asymmetric key, how to publish the public material via the JWKS endpoint, and the trade-offs.
When to choose which alg
| Alg | Use when |
|---|---|
HS256 | All verifiers are Parsec processes you control. Smallest tokens, simplest rotation. (Default.) |
EdDSA | You need a public key for external verifiers and you control the consumer libraries. Smaller keys (32-byte private seed), faster signing than RS256. |
ES256 | NIST-curve interop required (FIPS-leaning environments, hardware modules that don’t speak Ed25519). 64-byte signatures, P-256 keys, fast verification. |
ES384 | Same as ES256 but with a P-384 curve when a 192-bit security level is required by policy. 96-byte signatures. |
RS256 | You need broad ecosystem interop (most enterprise SSO / JWKS libraries assume RSA). Largest keys (2048+ bit modulus), larger signatures, slower signing. |
You can mix algorithms in one ring — Parsec dispatches per key. A common deployment runs HS256 for internal mgmt tokens and Ed25519 (or ES256) for client tokens consumed by an edge verifier.
Adding an asymmetric key
# Default (back-compat) is still HS256 — same as before.
parsec keys generate
# Ed25519 (joins as verify-only):
parsec keys generate --alg eddsa
# RS256 with the default 2048-bit modulus:
parsec keys generate --alg rs256
# ECDSA on P-256 / P-384:
parsec keys generate --alg es256
parsec keys generate --alg es384
Promote to active when you are ready to start signing with it:
parsec keys list # find the new key id
parsec keys promote <new-kid>
The previously-active key transitions to verify-only so existing
tokens stay valid until they expire. Retire the old key after the
longest token TTL elapses (same procedure as
key rotation).
JWKS endpoint
Once any non-retired key in the ring is asymmetric, Parsec serves a JWKS document (RFC 7517) at:
GET /parsec/jwks.json
The handler returns one JWK per asymmetric key. HMAC keys are never exposed — they are shared secrets, not verifying material.
A minimal Ed25519 JWKS entry looks like:
{
"kty": "OKP",
"kid": "k-9a3f1c0b1e22",
"alg": "EdDSA",
"use": "sig",
"crv": "Ed25519",
"x": "<base64url public key>"
}
ECDSA JWKS entries carry kty=EC with both coordinates of the public
point. The x and y fields are fixed-width per curve (32 bytes for
P-256, 48 bytes for P-384) before base64url encoding:
{
"kty": "EC",
"kid": "k-1f3b8a04c011",
"alg": "ES256",
"use": "sig",
"crv": "P-256",
"x": "<base64url X coordinate, 32 bytes>",
"y": "<base64url Y coordinate, 32 bytes>"
}
The response is cacheable (Cache-Control: public, max-age=300).
External verifiers should respect that header and re-fetch after the
TTL or on a kid lookup miss.
/parsec/jwks.json is unauthenticated by design — verifying parties
must be able to reach it without a Parsec credential. Mount it behind
a CDN with appropriate firewalling if reachability needs to be
controlled.
When the ring contains only HMAC keys, the route returns 404. There is no entry to expose, so probing yields no information.
Wire shape — what changes in the token
Every JWT carries alg in its header. Parsec writes one of HS256,
RS256, EdDSA, ES256, or ES384. The verifier checks the header
alg against the algorithm of the key it points to (kid); a
mismatch is rejected with PARSEC_AUTH_DENIED to defeat key-confusion
attacks. There is no algorithm negotiation — the kid uniquely
determines the signing alg.
ECDSA signatures are written in the JOSE fixed-width form (RFC 7515
§3.4): the raw r||s integers padded to the curve’s coordinate size
(32 bytes for P-256, 48 bytes for P-384), NOT the ASN.1/DER wrapper
that ECDSA APIs typically emit. The verifier rejects any signature
whose length isn’t exactly 2 * coord_size so a DER-shaped signature
cannot be smuggled in. Other libraries that produce DER need to
re-encode to fixed-width when interoperating.
Persistence
The keyring snapshot format version bumps from 1 to 2 to carry
the per-key alg and private_pem (PKCS#8 for RS256, EdDSA, and
both ECDSA curves). Loaders accept both versions:
- v1 (or v2 with
alg="") is treated as HS256, falling back to thesecret_hexfield. Legacy on-disk rings load without operator intervention. - v2 with
alg=RS256/alg=EdDSA/alg=ES256/alg=ES384decodesprivate_pemand reconstructs the typed key. For ECDSA the loader cross-checks that the encoded curve matches the declared alg (P-256 ↔ ES256, P-384 ↔ ES384) — a mismatch refuses to load.
File mode + parent dir mode are unchanged (0600 / 0700). PKCS#8
encoding means the on-disk blob carries the algorithm internally, so
the loader cross-checks the declared alg against the parsed key
type — a mismatch refuses to load the ring rather than guess.
What is not yet supported
- Algorithm-per-token-type (e.g. mgmt = HS256, access = EdDSA). The active key dictates the alg for every issuance.
- ECDSA on P-521 (ES512). JOSE defines it, but parsec pins the supported curves to P-256 and P-384; larger curves bring variable-length JOSE signatures and no compelling operator benefit.
- JWKS rotation hints (the
algfield is per-key; we do not currently emitx5cor chain metadata).
Metrics and audit
parsec_token_verifications_total{type, result} is unchanged. The
metric does not break down by alg — adding it would create a
five-cell label set per token type for limited operator benefit. The
access log records the kid that verified each request; correlate
against parsec keys list to see which alg signed it.
OIDC Bridge for Management Authentication
Parsec’s management RPC accepts two kinds of bearer tokens:
- HMAC mgmt tokens — minted by
parsec tokens mgmt(orIssueMgmt) and signed by the active key in the parsec keyring. - OIDC ID tokens — issued by a corporate IdP (Okta, Auth0, Google Workspace, Keycloak) and verified against the issuer’s JWKS endpoint at request time.
OIDC is opt-in. When auth.oidc.issuer is empty (the default), the
only accepted mgmt tokens are HMAC-signed parsec tokens.
Why OIDC?
Parsec HMAC tokens are well-suited to service-to-service automation: short-lived, key-rotation-aware, and minted by the parsec instance itself. Human operators are different — corporate SSO is the expected entry point, and provisioning a per-operator HMAC token just to call the management RPC adds operational friction.
Mounting OIDC alongside HMAC means operators authenticate with the same identity their IT department already manages, while automation keeps using the existing token issuer.
Architecture
+-----------------+
bearer token -> | bearer middleware| --> twirp handler
+-----------------+
|
v
+-----------------+
| CompositeVerifier|
+-----------------+
/ \
v v
+--------+ +-----------+
| HMAC | | OIDC |
+--------+ +-----------+
(keyring) (issuer JWKS)
The bearer middleware tries HMAC first. On any failure (malformed, expired, signature mismatch, unknown kid), it falls through to the OIDC verifier, which:
- Re-fetches the IdP’s JWKS if the cached keys are stale.
- Verifies the token’s signature against the JWKS.
- Enforces
exp,iat,aud, andiss. - Translates the payload into a synthetic
auth.Claims:sub<- the claim named bysubject_claim(defaults tosub)typ<-"mgmt"scopes<- derived from the IdP groups viaauth.oidc.grants
The synthetic claims object has the same shape as an HMAC-issued mgmt token, so downstream code (the rate limiter, scope authorizer, access log) does not need an OIDC-specific code path.
Configuration
OIDC is configured via the YAML config file under auth.oidc:
auth:
oidc:
issuer: https://accounts.google.com
audience: parsec-prod
# Optional claim mapping. Defaults: sub -> subject; groups -> scopes.
subject_claim: email
scopes_claim: groups
# Map IdP group names to parsec verbs / channel patterns.
grants:
- if_group: parsec-admins
scope: "*:**"
verbs: [subscribe, publish, manage]
- if_group: parsec-readers
scope: "public:**"
verbs: [subscribe]
| Key | Purpose | Default |
|---|---|---|
issuer | OpenID issuer URL. Empty disables OIDC. | (none) |
audience | Expected aud claim — the client identifier registered with the IdP. Required when issuer is set. | (none) |
subject_claim | Which claim becomes Claims.Sub. Common: email, preferred_username, sub. | sub |
scopes_claim | Which claim carries the operator’s group memberships. | groups |
grants[].if_group | Group name to match against the token’s scopes_claim. Case-sensitive. | (required) |
grants[].scope | parsec scope pattern to grant (channel grammar + * / **). | (required) |
grants[].verbs | Subset of [subscribe, publish, manage]. | (required) |
auth.oidc.issuer accepts the same ${ENV_VAR} interpolation as
every other string field in the config.
Setup walkthroughs
The examples below show the placeholder IdP-side settings needed to
make parsec a registered client. Adjust ${PARSEC_HOSTNAME} and
group names to your deployment.
Google Workspace
-
In Google Cloud Console, create an OAuth 2.0 client of type “Web application” or “Desktop” (for device-code flow).
-
Set the client ID as the parsec audience.
-
Add the
https://accounts.google.com/.well-known/openid-configurationissuer to parsec’s config:auth: oidc: issuer: https://accounts.google.com audience: ${GOOGLE_CLIENT_ID} subject_claim: email # Google does not ship group claims by default. Pair this # with the Cloud Identity API or push groups through a # custom claim during sign-in. scopes_claim: groups grants: - if_group: parsec-admins@example.com scope: "*:**" verbs: [subscribe, publish, manage] -
On the operator’s laptop:
parsec login oidc \ --issuer https://accounts.google.com \ --client-id "$GOOGLE_CLIENT_ID"The CLI prints a verification URL and a user code. Open the URL, sign in, paste the code — the CLI receives the ID token and writes it to
~/.parsec/credentials.
Okta
-
In Okta, create an OIDC application of type “Native” (device grant supported).
-
Enable “Device Authorization Grant” under the General tab.
-
Pin a custom claim that includes the user’s group memberships (e.g.
groups<-getFilteredGroups(...)).auth: oidc: issuer: https://${OKTA_DOMAIN}/oauth2/default audience: ${OKTA_CLIENT_ID} subject_claim: email scopes_claim: groups grants: - if_group: parsec-admins scope: "*:**" verbs: [subscribe, publish, manage] - if_group: parsec-readers scope: "public:**" verbs: [subscribe] -
Operator login:
parsec login oidc \ --issuer https://${OKTA_DOMAIN}/oauth2/default \ --client-id "$OKTA_CLIENT_ID"
Keycloak
-
In the realm of your choice, create a client with “Standard Flow” + “OAuth 2.0 Device Authorization Grant” enabled.
-
Set the client’s access type to “public” (or “confidential” with a client secret — pass
--client-secrettoparsec login oidcin that case). -
Map the user’s groups onto a claim named
groups.auth: oidc: issuer: https://${KEYCLOAK_HOST}/realms/${REALM} audience: ${KEYCLOAK_CLIENT_ID} subject_claim: preferred_username scopes_claim: groups grants: - if_group: /parsec-admins scope: "*:**" verbs: [subscribe, publish, manage] -
Operator login:
parsec login oidc \ --issuer https://${KEYCLOAK_HOST}/realms/${REALM} \ --client-id "$KEYCLOAK_CLIENT_ID" \ --client-secret "$KEYCLOAK_CLIENT_SECRET" # optional
CLI usage
After parsec login oidc completes, the persisted ID token at
~/.parsec/credentials is picked up by subsequent CLI invocations
that read PARSEC_TOKEN from --token. To inspect the file:
cat ~/.parsec/credentials
To clear it (sign out):
parsec logout
The CLI does NOT refresh expired ID tokens automatically; when the
token expires, run parsec login oidc again.
Verifying the bridge is live
The manifest exposes two fields the operator can query:
curl -s http://localhost:8000/manifest | jq '.payload | {auth_oidc_enabled, auth_oidc_issuer}'
auth_oidc_enabled: true and a populated auth_oidc_issuer confirm
parsec discovered the issuer’s JWKS at boot.
Security notes
- Parsec verifies the token via the IdP’s JWKS only — there is no
RFC 7662 token introspection. If the operator’s IdP revokes the
token mid-session, parsec will keep accepting it until
exp. Operators with strict revocation requirements should configure shortexplifetimes on the IdP side. - The OIDC verifier is constructed at boot. The JWKS is re-fetched by the underlying go-oidc library when the cached keys can’t verify a token (key rotation is handled transparently).
- An operator whose groups don’t match any grant still authenticates but receives an empty scope set — they can read the manifest but not subscribe / publish / manage. Configure a default-deny grant if you want stricter behavior.
- A deployment without
auth.oidc.issueraccepts HMAC tokens only — the OIDC verifier is not constructed and the bearer middleware skips the JWKS path entirely.
Cross-references
- Key rotation runbook — the HMAC side of the story; OIDC operates orthogonally.
- Channel ACLs and scopes — the grammar
consumed by
auth.oidc.grants[].scope.
Token Broker
The token broker is the policy point for issuing user-facing connection
tokens. A backend service authenticates the end user, asks the broker
for a token scoped to a list of channels, the broker consults an
Authorizer, and emits a JWT the browser uses to connect via WebSocket
or HTTP-streaming.
The broker is opt-in: deployments wire Options.TokenBrokerHandler
when they want delegated issuance and revocation. The library facade
(parsec.New) does not require it — manifest-direct embeddings can
mint tokens via auth.Issuer directly.
Routes
When Options.TokenBrokerHandler = broker.Handler() is set, the
following routes mount under /parsec:
| Path | Body | Returns |
|---|---|---|
POST /parsec/token | IssueRequest { channels, ttl } | IssueResponse { token, token_id, expires_at, channels_granted, channels_denied } |
POST /parsec/token/delegate | DelegateRequest { on_behalf_of, channels, lifetime_seconds } | IssueResponse |
POST /parsec/revoke | RevokeRequest { token_id?, user_id?, reason? } | { "ok": true } |
Every route requires Authorization: Bearer <user-token>. The bearer
is passed verbatim to the broker’s Authenticator — Parsec does no
signature verification on the user-auth bearer.
Delegated issuance
A trusted backend service can mint a token “on behalf of” a different
user by POSTing to /parsec/token/delegate. The service’s own bearer
is the requester; on_behalf_of names the target user.
Configure Options.DelegateAuthorizer to gate which services may
delegate to which users:
b, _ := tokenbroker.New(tokenbroker.Options{
Issuer: issuer,
Authorizer: authorizer,
Authenticator: authn,
DelegateAuthorizer: func(ctx context.Context, svc, target tokenbroker.UserID) error {
if svc == "svc-notifier" && strings.HasPrefix(string(target), "user-") {
return nil
}
return tokenbroker.ErrUnauthenticated
},
})
When DelegateAuthorizer is nil, the broker accepts any delegation
the Authenticator admits — fine for single-tenant deployments,
risky in multi-tenant ones.
The audit log captures both the delegator and the on-behalf-of identity in every entry so investigations can trace who minted what.
Revocation
Token revocation requires two pieces:
- A
RevocationStorethat survives across nodes. Options.RevocationStoreonparsec.Newso the subscribe authorizer consults the store before allowing a private subscribe.
Stores
| Store | Use case |
|---|---|
tokenbroker.NewMemoryRevocations() | Single-node dev/test. Entries age out after MaxTTL (default 24h); start the pruner with store.StartPruner(ctx, interval) to reclaim map memory. |
tokenbroker.NewRedisRevocations(client) | Multi-node production. Revocation entries SET with EX MaxTTL. Tune with WithMaxTTL(d). Keys live under <prefix>:tb:revoked:* and <prefix>:tb:user_revoked_at:*. |
store := tokenbroker.NewRedisRevocations(redisClient).
WithKeyPrefix("parsec").
WithMaxTTL(36 * time.Hour) // cover the longest mgmt TTL + a margin
b, _ := tokenbroker.New(tokenbroker.Options{
Issuer: issuer,
Authorizer: authorizer,
Authenticator: authn,
Revocations: store,
})
p, _ := parsec.New(parsec.Options{
KeyRing: ring,
TokenBrokerHandler: b.Handler(),
RevocationStore: store, // SAME store — broker and subscribe path share it
})
When Options.RevocationStore is left nil, the broker can still mark
tokens revoked in its own copy, but the subscribe authorizer never
consults that copy — connections accepted before the revoke continue
to subscribe. The manifest exposes revocation_store_enabled: false
to flag this gap to operators.
Scopes
- Single-token revoke (
token_id): the named token cannot be used to subscribe again. Re-subscribe attempts returnPARSEC_AUTH_DENIED. - User blanket revoke (
user_id): every token issued to the user before the revoke moment is rejected. Tokens minted after the revoke remain valid — operators who want a full lock-out also need to stop minting new tokens for the user upstream.
The subscribe authorizer reads the token’s jti (added to every
access token automatically) and sub claims. Tokens minted before
this PR landed do not carry a jti and cannot be single-token revoked;
those tokens still age out naturally at their exp claim and the
user-blanket revoke still applies.
Manifest exposure
The public Manifest now surfaces both states so SDKs and dashboards can discover broker availability without sniffing routes:
{
"kind": "parsec.manifest",
"payload": {
"token_broker_enabled": true,
"revocation_store_enabled": true
}
}
Audit log
Pass an AuditLogger to capture every successful issuance:
b, _ := tokenbroker.New(tokenbroker.Options{
// ... Issuer / Authorizer / Authenticator
AuditLog: tokenbroker.AuditLoggerFunc(func(ctx context.Context, e tokenbroker.AuditEntry) {
slog.Info("token issued",
"token_id", e.TokenID,
"user", e.IssuedTo,
"delegator", e.Delegator,
"channels", e.Channels,
"expires_at", e.ExpiresAt,
)
}),
})
Audit entries are best-effort observation — they fire after the JWT is signed and the revocation store is updated. A panicking logger will not undo an issuance.
Operator-side CLI
The Twirp service exposes two operator-driven revoke RPCs gated by the mgmt bearer. The CLI is the canonical front door:
# Single token (the access token's jti claim, which is the same value
# the broker returns in IssueResponse.token_id).
$ parsec tokens revoke <token-id> --user <user-id> --reason "compromised"
# Blanket-revoke every token issued to a user before now.
$ parsec tokens revoke-user <user-id> --reason "password reset"
PARSEC_TOKEN (or --token) supplies the mgmt bearer the same way
every other parsec subcommand consumes one. The CLI is just a thin
wrapper around the RPC; backends that need to call from code should
hit RevokeToken / RevokeUser directly via the generated Twirp
client.
PARSEC_INVALID_ARGUMENT is returned when Options.RevocationStore is
nil — the operator-facing path refuses to silently no-op so a missing
wiring is discovered loudly. The user-facing /parsec/revoke HTTP
route remains the right surface for end-user-driven flows (e.g. a
“sign me out everywhere” button) because it consumes the user’s own
bearer via the broker’s Authenticator.
Operational runbook — revoking a compromised token
- Reach for the issuance audit log to find the
token_id. parsec tokens revoke <id> --user <user-id> --reason "compromised"(or the rawcurl -X POST -H "Authorization: Bearer $MGMT" -d '{"token_id":"<id>","reason":"compromised"}' https://parsec.example.com/twirp/parsec.ParsecService/RevokeTokenTwirp call).- Verify the revocation is visible by reissuing a fresh token for the
same user — the new token’s
token_idwill differ and the old one will be rejected on its next subscribe. - If the compromise scope is broader than one token, blanket-revoke
the user with
parsec tokens revoke-user <id> --reason "...".
Request-Hash Cache
The cache package gives Parsec applications a request-hash cache for
sharing computed results across users and sessions. The cache stores
envelope.Envelope values (not raw bytes), so a hit looks identical to
a fresh computation from the subscriber’s point of view.
Two implementations ship — pick the one that fits the deployment:
| Backend | Use case |
|---|---|
cache.NewMemoryCache(maxEntries) | Single-node dev/test or per-process hot path. LRU + per-entry TTL; bounded by maxEntries with a background sweeper. |
cache.NewRedisCache(client, prefix) | Multi-node production. Shared keyspace under <prefix> (default parsec:cache); per-entry TTL via Redis EX. |
cache.NewNoopCache() | Explicit opt-out. Every Get misses; writes drop. Useful when Options.RedisClient is set but you don’t want the auto-built Redis cache. |
Wiring
import (
"github.com/frankbardon/parsec"
"github.com/frankbardon/parsec/cache"
)
p, err := parsec.New(parsec.Options{
KeyRing: ring,
// Either pass an explicit cache:
Cache: cache.NewMemoryCache(8192),
// ...or set RedisClient and parsec.New auto-builds a RedisCache:
// RedisClient: redisClient,
})
Access the wired cache via p.Cache(). When no cache is configured,
p.Cache() returns nil — embedders that consume the cache
opportunistically should treat that as “caching disabled.”
The manifest exposes both states so SDKs and dashboards can pick the right behavior without sniffing internals:
{
"kind": "parsec.manifest",
"payload": {
"cache_enabled": true,
"cache_backend": "redis"
}
}
cache_backend is one of memory, redis, noop, custom, or
empty (cache disabled).
Tenant scoping
The cache surface ships paired tenant-scoped methods for every
operation — GetForTenant, PutForTenant, DeleteForTenant. Keys
are namespaced with <tenantID>: so cross-tenant leakage is
impossible by construction when call sites consistently use the
tenant-scoped API.
cached, ok, _ := p.Cache().GetForTenant(ctx, tenantID, key)
if !ok {
env := computeExpensiveResult(ctx, ...)
_ = p.Cache().PutForTenant(ctx, tenantID, key, env, 5*time.Minute)
cached = env
}
Metrics
Every cache operation increments the bundled Prometheus collectors:
| Metric | Type | Labels |
|---|---|---|
parsec_cache_operations_total | counter | op (get/put/delete), result (hit/miss/success/failure) |
parsec_cache_size_entries | gauge | backend (memory/redis/noop/custom) |
The size_entries gauge is sampled at every Stats() call. Redis
caches report their local view (this process’s hits/misses); operators
who want a global view should consult Redis’ own INFO memory.
Telemetry adapter
The aggregated /parsec/metrics JSON view picks up cache hit rate via
the telemetry.CacheSource. The adapter wires it in one line:
import "github.com/frankbardon/parsec/telemetry"
agg := telemetry.New(
channelSrc,
envSrc,
tokenSrc,
telemetry.NewCacheSourceFromCache(p.Cache()), // nil-safe
)
opts.TelemetryHandler = agg.Handler()
When p.Cache() is nil, NewCacheSourceFromCache returns nil and the
aggregator’s other sources keep working.
Adding a new backend
A custom backend must:
- Implement the full
cache.Cacheinterface (Get/Put/Deleteplus the three tenant-scoped twins plusStats). - Return a
cache.Statsstruct withHits/Misses/Puts/Evictions/SizeEntriesso the telemetry adapter and the metrics wrapper read stable numbers. - Optionally implement
cache.BackendReporterif the backend should identify itself in the manifest as something other thancustom.
Pass it to parsec.Options.Cache and the rest of the wiring — manifest
exposure, metrics, telemetry — works without further changes.
Observability
Every Parsec deployment exposes three streams: Prometheus metrics at
/metrics, OpenTelemetry traces to an OTLP collector, and
structured access logs through slog.
The defaults are operator-friendly: metrics are always on; tracing is
off until you set an endpoint; the access log uses the Logger you
pass into parsec.Options (or slog.Default() if none).
Metrics
Scraping /metrics
The route is mounted on the same HTTP mux as everything else. Set
Options.MetricsBearerToken to require Authorization: Bearer <token>
on every scrape; leave it empty to run unguarded (firewall the route
at the network layer in that case).
curl -H "Authorization: Bearer $METRICS_TOKEN" \
http://parsec.internal:8000/metrics
The registry includes Go runtime + process collectors by default. To
share a registry with the rest of your service, pass
Options.MetricsRegistry.
Metric reference
| Metric | Type | Labels | Source |
|---|---|---|---|
parsec_publishes_total | counter | channel_visibility, result | every Publish call |
parsec_publish_duration_seconds | histogram | channel_visibility | every Publish call |
parsec_subscribers_active | gauge | channel_visibility | broker subscribe / unsubscribe hooks |
parsec_channels_active | gauge | state, visibility | sweeper, after every tick |
parsec_token_verifications_total | counter | type, result | every Verifier.Verify |
parsec_sink_attempts_total | counter | sink, result | every sink Send |
parsec_sink_duration_seconds | histogram | sink | every sink Send |
parsec_dlq_size | gauge | sink | periodic scrape (sweep interval) |
parsec_key_rotations_total | counter | action | GenerateKey / PromoteKey / RetireKey |
parsec_rpc_requests_total | counter | method, status | HTTP middleware (last URL segment) |
parsec_rpc_duration_seconds | histogram | method | HTTP middleware |
result values are success or failure. channel_visibility is
public or private (or unknown for the rare race where a name has
not been parsed). action for key_rotations_total is one of
generated, promoted, retired.
Cardinality contract
Channel names, subject IDs, and bearer tokens are never labels. The label set is bounded by:
channel_visibility∈ {public,private,unknown}result∈ {success,failure}type∈ {access,refresh,mgmt,unknown}sink— bounded by the registered sink registry (3 in the default build)state∈ {open,closed,deleted}method— the last URL segment of a/twirp/parsec.ParsecService/path; anything outside the prefix is collapsed to the literalnon_rpc
If you add a new subsystem with internal state, add a metric or document why none is needed (see CLAUDE.md Update Demand).
Tracing
Set Options.OTLPEndpoint to enable the OTLP HTTP exporter:
parsec.Options{
OTLPEndpoint: "otel-collector.observability:4318",
}
When the endpoint is empty (the default), internal/tracing.NewTracer
returns a no-op tracer that performs no allocations. There is no
runtime cost to leaving tracing off.
The service name is baked in as parsec. The exporter uses insecure
transport — terminate TLS at your collector or run a sidecar.
Each RPC request gets a server span; sub-spans for token verification, broker publish, and sink delivery are emitted by the corresponding subsystems. Trace context propagates via standard W3C headers — Parsec is a participant, not an originator.
Access logs
The HTTP surface emits one slog INFO line per request:
{
"time": "2026-05-22T14:03:11.123Z",
"level": "INFO",
"msg": "http_request",
"method": "POST",
"path": "/twirp/parsec.ParsecService/Publish",
"status": 200,
"duration_ms": 7,
"remote_addr": "203.0.113.42",
"request_id": "f9e3c0d1...",
"bearer_subject": "ops-alice",
"trace_id": "9c5f1a..."
}
Failed token verifications emit a WARN line with a PARSEC_AUTH_*
code; the token contents are never logged:
{
"level": "WARN",
"msg": "auth_failure",
"code": "PARSEC_AUTH_EXPIRED",
"request_id": "f9e3c0d1..."
}
Trusting X-Forwarded-For
Options.TrustedProxies is the list of CIDRs Parsec will honor an
X-Forwarded-For chain from. If the immediate TCP peer is not in the
list, XFF is ignored and the TCP peer is logged. This prevents any
unauthenticated client from spoofing their IP by setting a header.
_, lb, _ := net.ParseCIDR("10.0.0.0/8")
opts := parsec.Options{
TrustedProxies: []net.IPNet{*lb},
}
Request IDs
The middleware honors X-Request-ID when the client provides one and
generates a fresh 24-character hex value otherwise. The chosen ID is
echoed back in the response so clients can correlate.
Grafana dashboard
A starter dashboard JSON lives at
examples/grafana/parsec-overview.json.
It includes panels for:
- publish rate by visibility
- sink success ratio
- active channel inventory
- token verification failures
- key rotation activity
Import via the Grafana UI (Dashboards → Import → Upload JSON).
Telemetry alerts and Prometheus exposition
The aggregated /parsec/metrics JSON view can also surface declarative
alert firings and a parallel Prometheus text exposition for external
scraping. See
Telemetry Alerts & Prometheus Exposition for
the rule shape, metric reference, and AlertManager wiring.
Telemetry Alerts & Prometheus Exposition
The telemetry package aggregates per-source dashboard metrics into a
single Snapshot served as JSON at /parsec/metrics. This page covers
two extensions that operators commonly want on top:
- Declarative alert rules that fire when an aggregate value crosses a threshold.
- Prometheus text exposition of the same Snapshot so an external
Prometheus + AlertManager pair can scrape it alongside the
process-level
/metricsregistry.
The existing JSON view at /parsec/metrics is unchanged — alerts
attach to the same Aggregator and surface in Snapshot.Alerts.
Alert rules
An AlertRule is a name, severity, description, and a Condition
closure that receives the freshly computed Snapshot and returns
true when the rule should fire.
import "github.com/frankbardon/parsec/telemetry"
agg := telemetry.New(channelSrc, envSrc, tokenSrc, cacheSrc)
agg, err := agg.WithAlerts([]telemetry.AlertRule{
{
Name: "cache_cold",
Severity: telemetry.SeverityWarning,
Description: "Cache hit rate dropped below 80%",
Condition: func(s telemetry.Snapshot) bool {
return s.Cache.HitRatePct < 80
},
},
{
Name: "history_pressure",
Severity: telemetry.SeverityCritical,
Description: "History buffers above 90% utilization",
Condition: func(s telemetry.Snapshot) bool {
return s.History.BufferUtilizationPct > 90
},
},
})
if err != nil {
log.Fatalf("alert rules: %v", err)
}
opts := parsec.Options{TelemetryHandler: agg.Handler()}
WithAlerts runs ValidateAlertRules first — empty names, unknown
severities, nil Condition closures, and duplicate names abort the
boot (matching the rest of parsec’s “fail loud on misconfiguration”
posture).
Severities
| Severity | Meaning |
|---|---|
info | The condition is unusual but does not require action. |
warning | Investigate; the system is still serving traffic. |
critical | Immediate action — user impact imminent or active. |
JSON output
Snapshot.Alerts is omitted from the JSON when empty and rendered as
an array of firing rules when not:
{
"channels": {"total_active": 12},
"tokens": {"active_count": 4810},
"cache": {"hit_rate_pct": 73.2},
"alerts": [
{
"name": "cache_cold",
"severity": "warning",
"description": "Cache hit rate dropped below 80%"
}
],
"at": "2026-06-06T19:00:00Z"
}
Prometheus exposition
Aggregator.PrometheusHandler() returns an http.Handler that
re-aggregates on every scrape and writes the Snapshot in Prometheus
text exposition format (version 0.0.4). Mount it next to the JSON
handler:
mux := http.NewServeMux()
mux.Handle("/parsec/metrics", agg.Handler()) // JSON
mux.Handle("/parsec/telemetry/metrics", agg.PrometheusHandler()) // Prometheus
Operators can also wire it as Options.TelemetryHandler directly
when they only want the Prometheus view.
Metric reference
| Metric | Type | Labels |
|---|---|---|
parsec_telemetry_channels_active | gauge | — |
parsec_telemetry_channels_by_pattern | gauge | pattern |
parsec_telemetry_envelope_rate_per_second | gauge | — |
parsec_telemetry_envelopes_by_aspect | gauge | aspect |
parsec_telemetry_presence_total_users | gauge | — |
parsec_telemetry_presence_total_agents | gauge | — |
parsec_telemetry_presence_average_per_channel | gauge | — |
parsec_telemetry_history_buffered | gauge | — |
parsec_telemetry_history_buffer_utilization_pct | gauge | — |
parsec_telemetry_tokens_issued_last_hour | gauge | — |
parsec_telemetry_tokens_revoked_last_hour | gauge | — |
parsec_telemetry_tokens_active | gauge | — |
parsec_telemetry_cache_hit_rate_pct | gauge | — |
parsec_telemetry_cache_size_bytes | gauge | — |
parsec_telemetry_cache_size_entries | gauge | — |
parsec_telemetry_cache_evictions_last_hour | gauge | — |
parsec_telemetry_alerts_firing | gauge | alert, severity |
parsec_telemetry_alerts_firing only emits samples for rules that are
currently firing (value 1); a rule that no longer fires simply
disappears from the scrape. Operators who want a “0 when not firing”
exposition shape should add their own AlertManager rule that uses
absent_over_time.
Cardinality discipline
Only bounded labels escape the handler:
pattern— the configured channel grammar pattern. The embedder controls this set when constructing theChannelLister.aspect— the envelope kind reported byEnvelopeCounters.Observe. Constrain it at the call site (data / cursor / heartbeat / etc.).alert— theAlertRule.Name. Operators own the rule list.severity— one ofinfo,warning,critical.
Channel names, subject IDs, and bearer tokens are NEVER labels —
matching the existing /metrics cardinality contract.
AlertManager wiring
A starter prometheus.yml snippet:
scrape_configs:
- job_name: parsec-telemetry
static_configs:
- targets: ["parsec.internal:8000"]
metrics_path: /parsec/telemetry/metrics
rule_files:
- parsec_telemetry_rules.yml
A matching parsec_telemetry_rules.yml:
groups:
- name: parsec-telemetry
rules:
- alert: ParsecCacheCold
expr: parsec_telemetry_alerts_firing{alert="cache_cold"} == 1
for: 5m
labels:
severity: warning
annotations:
summary: "Parsec cache hit rate below threshold"
- alert: ParsecHistoryPressure
expr: parsec_telemetry_alerts_firing{alert="history_pressure"} == 1
for: 2m
labels:
severity: critical
Because the alert metric already encodes the rule semantics, the
Prometheus rule simply checks for the gauge’s presence. Operators who
want richer alerting can run additional expr rules over the
underlying gauges (parsec_telemetry_cache_hit_rate_pct < 80) instead
of the firing gauge.
Sink dead-letter queue (DLQ)
Sinks transparently retry transient failures with exponential backoff + jitter. Anything that survives the retry budget — or is classified as terminal from the first call — lands in the DLQ. An operator inspects, replays, or discards items from there.
Backends
| Backend | When it activates | Persistence |
|---|---|---|
memory | Options.RedisClient == nil | Process-local; lost on restart |
redis | Options.RedisClient != nil (or --redis-addr) | Redis Streams, one stream per sink (parsec:dlq:<sink>) |
The backend in use is reported in the dlq_backend field of
parsec --json.
Retry policy
Default config (overridable via Options.SinkRetry or
Options.PerSinkRetry):
| Field | Default |
|---|---|
MaxAttempts | 5 |
BaseBackoff | 1s |
MaxBackoff | 30s |
JitterFraction | 0.2 |
Backoff per attempt is BaseBackoff * 2^(attempt-2) capped at
MaxBackoff, multiplied by uniform(1 - JitterFraction, 1 + JitterFraction)
to break thundering herds.
Set MaxAttempts = 1 to disable retries (terminal-or-DLQ on first error).
CLI
parsec dlq list <sink> # newest first
parsec dlq count <sink> # XLEN
parsec dlq discard <id> # XDEL
parsec dlq replay <id> # re-run through the original sink
Every command honors --server and --token (or PARSEC_SERVER /
PARSEC_TOKEN).
Inspecting items
$ parsec dlq list email --limit 10
{
"format_version": "1.0",
"kind": "parsec.dlq.list",
"payload": {
"sink": "email",
"items": [
{
"id": "1741310221012-0",
"sink": "email",
"at": "2026-05-22T18:30:21Z",
"subject": "Order shipped",
"body": "Your package is on the way.",
"attempts": 5,
"last_error": "smtp send: 451 4.4.5 Temporary error",
"recipient": {"address": "alice@example.com"}
}
]
}
}
Replaying
Replay reruns the stored item through the original sink. If the sink is
wrapped by a Retrier (the default), the replay itself retries up to
MaxAttempts. If it fails again it lands back in the DLQ as a new
item — the original entry stays in place so you can compare attempt
counts.
$ parsec dlq replay 1741310221012-0
{ "kind": "parsec.dlq.replayed", "payload": {"id": "1741310221012-0"} }
Discarding
discard is final. Use it when the payload is irrelevant or the failure
was diagnosed and the item is unrecoverable.
$ parsec dlq discard 1741310221012-0
RPC
| Method | Request | Response |
|---|---|---|
DlqList | {sink, limit} | {items[]} |
DlqCount | {sink} | {count} |
DlqDiscard | {id} | Empty |
DlqReplay | {id} | Empty |
All methods require a mgmt bearer.
Stream growth & retention
The Redis backend uses XADD MAXLEN ~ 10000 by default — old entries are
trimmed approximately when the stream grows past the cap. Override via
RedisDLQ.WithMaxLen(n) for tighter or looser retention.
Error taxonomy
| Code | Meaning |
|---|---|
PARSEC_SINK_UNAVAILABLE | Transient sink failure (retry will run) |
PARSEC_SINK_DLQ_OVERFLOW | Retries exhausted and DLQ push itself failed |
PARSEC_SINK_DLQ_NOT_FOUND | Replay/Discard referenced an unknown id |
PARSEC_SINK_CONFIG_INVALID | Sink misconfigured at registration time |
PublishOrSink returns err == nil when the DLQ accepted the failed
payload — the failure has been recorded. It returns an error only when
the DLQ itself failed (overflow / Redis unreachable).
Rate Limiting
Parsec gates three abusable ingress points with configurable budgets:
| Bucket | What it gates | Default key |
|---|---|---|
publish | The Publish RPC | mgmt-bearer subject |
subscribe | Centrifuge subscribe attempts | userID, when authenticated |
token-issue | The RefreshToken RPC | remote IP |
When a bucket is exhausted the caller receives a PARSEC_RATE_LIMITED
coded error, rendered as Twirp’s resource_exhausted and HTTP 429 with
an advisory Reset window. Single-node mode uses an in-process token
bucket; multi-node mode (when Options.RedisClient is set) shares the
budget across nodes via a Redis sorted-set sliding window driven by a
single-round-trip Lua script.
Quick start (CLI)
parsec serve \
--publish-rate 100/s --publish-burst 200 \
--subscribe-rate 20/s \
--token-issue-rate 5/s
The shorthand format is <integer>/<window>. The window accepts the
unit aliases s, m, h, plus any duration time.ParseDuration understands
(500ms, 2m30s). An empty rate string disables that bucket.
Environment-variable equivalents (for container deployments):
PARSEC_PUBLISH_RATE=100/s
PARSEC_PUBLISH_BURST=200
PARSEC_SUBSCRIBE_RATE=20/s
PARSEC_TOKEN_ISSUE_RATE=5/s
Library configuration
import "github.com/frankbardon/parsec/ratelimit"
p, _ := parsec.New(parsec.Options{
RateLimits: ratelimit.RateLimits{
Publish: ratelimit.Limit{Rate: 100, Per: time.Second, Burst: 200},
Subscribe: ratelimit.Limit{Rate: 20, Per: time.Second},
TokenIssue: ratelimit.Limit{Rate: 5, Per: time.Second},
},
})
Pass Options.Limiter to plug in a custom backend (e.g. a token-bucket
shared with another service). When Limiter is nil and any bucket has
Rate > 0, Parsec constructs:
ratelimit.RedisLimiterwhenOptions.RedisClientis setratelimit.MemoryLimiterotherwise
Cross-node behaviour
Two Parsec nodes pointing at the same Redis (and sharing
Options.RedisKeyPrefix) consume one global budget. The Lua script is
EVALSHA’d on first use and reloaded automatically on NOSCRIPT. Keys
take the form <prefix>:rl:<bucket>:<subject> and inherit
PEXPIRE(window + 10s) so abandoned buckets free themselves.
Per-token override
A mgmt token may carry an rl claim that overrides the operator default
for that subject. Mint such a token with
Issuer.IssuePairWithRateLimit:
override := ratelimit.Limit{Rate: 10, Per: time.Second}
pair, _ := p.Issuer().IssuePairWithRateLimit(sub, channel, ttl, override)
Server-side, the override is automatically picked up from the bearer’s claims on every gated call. The default is still applied to subjects without an override.
Overrides only narrow the budget — they cannot widen it past the operator-configured ceiling for an unauthenticated path (the token-issue bucket keys off IP, not the subject).
Per-channel publish ceilings
Operators can tighten (or loosen) the publish bucket for individual
channels or families of channels by mapping a channel-name pattern to
a Limit. The library option is PerChannelPublishLimits:
parsec.New(parsec.Options{
RateLimits: ratelimit.RateLimits{
Publish: ratelimit.Limit{Rate: 100, Per: time.Second, Burst: 200},
},
PerChannelPublishLimits: map[string]ratelimit.Limit{
// Internal metrics fanout — drown the broker without
// affecting other publishers.
"public:hot.metrics.**": {Rate: 500, Per: time.Second},
// Sensitive admin broadcasts — clamp to a handful per minute.
"private:admin.alerts.**": {Rate: 5, Per: time.Minute},
},
})
The pattern grammar is the channel-name grammar from
channels.ParsePattern (* matches one segment, trailing **
matches any remaining segments). Invalid patterns abort
parsec.New with PARSEC_INVALID_ARGUMENT so a typo never silently
disables a rule.
Rule selection: the most specific matching rule wins (literal
segments score 4, single-star segments 1, trailing ** 0; ties
broken by pattern string ascending). The selected rule’s Limit
overrides the default Publish budget for the call, and the bucket
key is namespaced publish-channel:<pattern>:<subject> so two rules
on disjoint channels never share a budget. Channels that match no
rule fall through to the default Publish bucket
(publish:<subject>).
Per-token overrides still apply on top: auth.Claims.RateLimitOverride
beats both the rule and the default. Use this when a single tenant
needs a tighter or looser ceiling on top of a global rule.
Per-channel subscribe ceilings
Subscribe-side limits use the same shape, configured via
PerChannelSubscribeLimits:
parsec.New(parsec.Options{
RateLimits: ratelimit.RateLimits{
Subscribe: ratelimit.Limit{Rate: 50, Per: time.Second},
},
PerChannelSubscribeLimits: map[string]ratelimit.Limit{
// Tenant-scoped channel — one subscribe per second per user.
"private:webapp.tenant.**": {Rate: 1, Per: time.Second},
// High-frequency presence channel — relax for embed widgets.
"public:status.heartbeat.**": {Rate: 200, Per: time.Second},
},
})
Rule selection matches the publish path (most-specific wins; literal
single-star > trailing
**; ties broken by pattern string). The bucket key is namespacedsubscribe-channel:<pattern>:<userID>; channels matching no rule fall through tosubscribe:<userID>with the globalSubscribebudget. Anonymous subscribes (userID empty because the transport never authenticated the connection) are not charged — operators wanting per-IP gating for unauthenticated traffic should enforce it at L7. The subscribe gate runs before token verification so a flood of bad-token attempts cannot exhaust CPU on HMAC verifies.
Per-token overrides are not consulted on the subscribe path: the access token’s subject is the rate-limit key, and a token cannot loosen the operator-configured budget for its own subject. To grant a single user a tighter (or looser) subscribe budget than the rule allows, encode the tenant in the channel name and add a more-specific rule.
Manifest discovery
The Manifest RPC exposes the default budgets:
{
"rate_limits": {
"publish": {"rate": 100, "per": "1s", "burst": 200},
"subscribe": {"rate": 20, "per": "1s", "burst": 20},
"token_issue": {"rate": 5, "per": "1s", "burst": 5}
}
}
Per-channel rules are appended under per_channel_publish and
per_channel_subscribe (each omitted when none configured):
{
"rate_limits": {
"publish": {"rate": 100, "per": "1s", "burst": 200},
"subscribe": {"rate": 20, "per": "1s", "burst": 20},
"token_issue": {"rate": 5, "per": "1s", "burst": 5},
"per_channel_publish": [
{"pattern": "public:hot.metrics.**", "limit": {"rate": 500, "per": "1s", "burst": 500}}
],
"per_channel_subscribe": [
{"pattern": "private:webapp.tenant.**", "limit": {"rate": 1, "per": "1s", "burst": 1}}
]
}
}
Per-token overrides are private to the issued token and never surface in the manifest.
Metrics
The parsec_rate_limit_decisions_total{bucket,result} counter
increments on every decision. bucket is publish, subscribe, or
token-issue; result is allowed or denied. Alert on
rate(parsec_rate_limit_decisions_total{result="denied"}[1m]) > 0 to
catch abusive callers.
Tuning
- Burst > Rate. Configure
burst~2xratefor bursty human-driven workloads; keep them equal for machine workloads where rate violations indicate misconfiguration. - Per-IP token-issue. The refresh endpoint has no mgmt bearer, so the IP is the only stable key. If Parsec sits behind a load balancer, put a complementary L7 rate limit on the LB and keep the parsec budget loose — the LB sees the real client IP, parsec sees only the LB.
- Single-node fallback.
MemoryLimiteris fine for dev and small deployments. Distinct keys live in a shared map; callSweepperiodically (or rely on the implicit drop-on-touch) if your key cardinality is large.
Failure modes
- Redis unreachable. A failed
Allowreturns the underlying redis error, which the RPC layer translates toPARSEC_INTERNAL(HTTP 500). The publish/subscribe paths fail closed — operators should monitorparsec_rate_limit_decisions_totaldivergence from request totals to detect this case. - Mixed-version clusters. EVALSHA / NOSCRIPT fallback handles rolling upgrades automatically.
WebTransport (HTTP/3)
Parsec ships an optional HTTP/3 WebTransport listener alongside the
default WebSocket transport. Both transports terminate at the same
centrifuge.Node, so auth, channel state, and subscribe authorization
behave identically — only the wire layer differs.
Use WebTransport when you want:
- Lower head-of-line blocking under packet loss (QUIC streams vs. one TCP connection).
- Faster reconnects across mobile network changes (connection migration).
- A drop-in path for browsers on networks that block long-lived WebSocket upgrades.
WebTransport requires TLS — there is no plaintext mode. Browsers refuse to upgrade an HTTP/3 transport without a valid certificate (or a known SPKI hash for local development).
Enabling the listener
Library
p, err := parsec.New(parsec.Options{
StateDir: "/var/lib/parsec",
WebTransport: parsec.WebTransportOptions{
Addr: ":8443", // UDP listen
TLSCertFile: "/etc/parsec/tls/server.crt",
TLSKeyFile: "/etc/parsec/tls/server.key",
AllowedOrigins: []string{
"https://app.example.com",
},
},
})
AllowedOrigins is the WT-level CORS gate. An empty slice means “allow
all” — appropriate for local dev only.
YAML
server:
addr: ":8000"
webtransport:
addr: ":8443"
tls_cert_file: /etc/parsec/tls/server.crt
tls_key_file: /etc/parsec/tls/server.key
allowed_origins:
- https://app.example.com
CLI
parsec serve \
--addr :8000 \
--webtransport-addr :8443 \
--webtransport-cert /etc/parsec/tls/server.crt \
--webtransport-key /etc/parsec/tls/server.key
The Twirp listener stays on :8000. The WT listener binds UDP on
:8443. Both share the same Parsec instance.
Path and protocol
WT clients connect to https://<host>:<wt-port>/connection/webtransport.
The handler upgrades the request to a WebTransport session and accepts a
single bidirectional stream that carries length-prefixed framed
centrifuge protocol messages — the same payload shape used over
WebSocket.
The manifest exposes the available transports:
{
"transports": ["websocket", "webtransport"]
}
Browser clients that detect webtransport in the manifest can prefer
HTTP/3 and fall back to WebSocket on unsupported networks.
Operating notes
- WebTransport listens on UDP — open the firewall for the chosen port (default UDP 8443) in addition to the TCP port serving Twirp / WebSocket.
- Some L7 load balancers do not yet proxy HTTP/3. Terminate TLS at the parsec process (or at a QUIC-aware proxy) until your edge supports it.
- Certificate rotation is identical to any HTTP/3 service: write a new cert pair and restart the process. There is no hot-reload yet.
- The WT transport uses the same access tokens as WebSocket. Clients
open a bidi stream with the access token as the first frame; subscribe
authorization runs through
auth.SubscribeAuthorizer, so deny-wins scope evaluation behaves identically.
Observability
Per-transport counters are emitted on the standard collectors:
parsec_transport_connections_total{transport="webtransport", result="ok"}
parsec_transport_connections_total{transport="webtransport", result="rejected"}
Access logs include transport=webtransport for the upgrade request. The
underlying QUIC handshake is not logged — operators wanting that detail
should enable quic-go’s tracer in a future release.
Disabling
Omit WebTransportOptions.Addr (or the YAML server.webtransport
block) and the entire HTTP/3 stack is excluded — no UDP listener, no
extra goroutines. The manifest then advertises "transports": ["websocket"] only.
Limitations
- No automatic certificate rotation (process restart required).
- Centrifuge OSS does not ship a WebTransport handler upstream;
parsec implements
wtTransportdirectly againstcentrifuge.Transport. Behavior matches the WebSocket transport but the implementation is parsec-local — file issues here, not against the upstream project. quic-gorequires Go 1.22+ (parsec targets 1.26.1) and ALSO requires a kernel with sufficient UDP socket buffer space on Linux — thenet.core.rmem_maxsysctl may need bumping for high-throughput deployments.
HTTP-Streaming Transport
Parsec mounts a bidirectional HTTP-streaming endpoint at
/connection/http_stream alongside the WebSocket transport. Both
transports terminate at the same centrifuge.Node, so auth, channel
state, and subscribe authorization behave identically — only the wire
layer differs.
Use HTTP-streaming when:
- A client network blocks WebSocket upgrades (corporate proxies, certain mobile carriers) but still allows long-lived HTTP/1.1 or HTTP/2 POST responses.
- You want an emulation transport for environments where WebSocket libraries aren’t available (e.g. minimal HTTP-only clients, serverless runtimes).
- You need a parallel fallback to WebSocket without standing up HTTP/3 + TLS for WebTransport.
Not the same as
/sse. The/sseendpoint is a tiny polling-backed Server-Sent Events probe used byparsec subscribe— it is not a production transport./connection/http_streamis a full bidirectional centrifuge transport that production SDKs use.
Wire format
| Direction | Shape |
|---|---|
| Client → server | A single POST body carrying the connect command (newline-delimited JSON, or octet-protobuf when Content-Type: application/octet-stream). |
| Server → client | Streaming response body; newline-delimited JSON frames (or framed protobuf for the binary protocol) until the client disconnects or the server closes. |
The handler sets the standard streaming response headers so reverse proxies don’t buffer:
Cache-Control: private, no-cache, no-store, must-revalidate, max-age=0
X-Accel-Buffering: no
Pragma: no-cache
Expire: 0
CORS preflight (OPTIONS) returns 204 No Content with
Access-Control-Allow-Methods: POST, OPTIONS so browser clients can
POST cross-origin.
Connecting from centrifuge-js
import { Centrifuge } from 'centrifuge';
const client = new Centrifuge({
transports: [
{ transport: 'websocket', endpoint: 'wss://parsec.example.com/connection/websocket' },
{ transport: 'http_stream', endpoint: 'https://parsec.example.com/connection/http_stream' },
],
// Token comes from your auth flow — see docs/src/library/options.md.
token: accessToken,
});
client.on('connected', (ctx) => console.log('transport:', ctx.transport));
client.connect();
The SDK falls back through the transports list in order: WebSocket
first, then HTTP-streaming when the upgrade is refused.
Manifest exposure
The transports field of the public Manifest advertises
http_stream so clients can discover availability:
{
"kind": "parsec.manifest",
"format_version": "1",
"payload": {
"transports": ["websocket", "http_stream"]
}
}
When WebTransport.Addr is also set, the list becomes
["websocket", "http_stream", "webtransport"].
Tuning
The handler accepts a centrifuge.HTTPStreamConfig (see the
centrifuge module). Parsec uses the zero value today — defaults are
fine for almost every deployment:
MaxRequestBodySizedefaults to 64 KiB.PingPongConfigdefaults to the node-wide ping/pong settings.
If you need to tighten the request-body cap, open an issue — we’ll
plumb the knob through parsec.Options.
Observability
HTTP-streaming connections appear in:
/metrics— the existing centrifuge collectors (centrifuge_num_clients,centrifuge_transport_messages_*) count http-stream sessions the same way they count WebSocket sessions.- Access log — every POST to
/connection/http_streamlands one structured INFO line withmethod=POST,path=/connection/http_stream,duration_ms(the full stream lifetime),bearer_subject(when the connect command carried a token).
Delta compression
Parsec exposes Centrifuge’s per-channel fossil-delta encoding as an opt-in flag. When enabled, the broker ships only the diff between the previous publication and the current one — the wire payload shrinks dramatically for high-frequency channels with small mutations (scoreboards, ticker feeds, partial state snapshots).
When to enable
Enable delta when all of the following hold:
- The channel publishes frequently (multiple times per second).
- Successive payloads share most of their bytes (incremental counters, small JSON patches, append-only logs).
- Subscribers can tolerate a one-message-out-of-order tail being recomputed from the prior payload.
Do not enable delta for:
- Sparse, infrequent publications (no compression headroom; the encoder overhead is pure cost).
- Mostly-unique payloads (deltas approach the full payload size — slower than no encoding at all).
- Channels where individual messages must remain self-contained on the wire (audit feeds, signed payloads).
Library API
ch, _ := p.OpenPublic("public:scoreboard.counter.global", time.Hour)
_ = p.Manager().SetDelta(ch.Name, true)
SetDelta(name, true) flips the channel’s DeltaEnabled flag in the
store. The broker reads the flag on every publish and chooses between
the plain and delta wire format. Toggling is hot — no reconnect
required.
Channel.DeltaEnabled is exposed on parsec channels get and through
the Twirp GetChannel RPC so operators can audit the state.
CLI
parsec channels open public:scoreboard.counter.global --delta
parsec channels create private:agent.analysis.{job}.progress --delta --subject job-42
For an already-open channel:
parsec channels set-delta public:scoreboard.counter.global --on
parsec channels set-delta public:scoreboard.counter.global --off
How it works
Parsec sets centrifuge’s WithDelta(true) publish option and configures
the Node with AllowedDeltaTypes: []DeltaType{DeltaTypeFossil}. The
encoder is the fossil-delta algorithm shipped in centrifuge OSS —
parsec adds the channel-level toggle and persistence; the wire encoding
is upstream.
Subscribers negotiate delta support during the connect handshake. A client that does NOT advertise fossil support receives the full payload — the broker degrades gracefully, never producing a delta the client cannot decode.
Manifest exposure
{
"delta_compression": "supported"
}
The field reports parsec capability, not per-channel state. Operators
inspecting individual channels see the flag on Channel.DeltaEnabled.
Multi-node coherence
When the Redis broker is active, the per-channel delta flag rides on
the channel registry HASH (parsec:ch:<name> → delta=1). Every node
reads the flag before publishing, so a delta channel stays delta across
the whole cluster without re-issuing tokens.
The SetDelta event also rides the channel event bus so cached
Channel snapshots stay in sync — clients of Manager.Subscribe see a
updated event whenever the flag flips.
Observability
parsec_publications_total{visibility="public", delta="true"}
parsec_publications_total{visibility="public", delta="false"}
The label is bounded to true / false so cardinality stays flat.
Caveats
- Delta is per-channel, not per-subscriber. A channel either ships deltas to clients that support fossil OR it does not.
- The encoder holds the previous payload in memory per channel — for very wide payloads (>1 MB) the memory overhead can matter; profile before enabling.
- History playback still ships full payloads — deltas are a transport optimization, not a storage format. Late subscribers receive the ground truth.
Multi-Region Deployments
Parsec is a single-region service by default. The minimum tooling
needed to run N regions and keep the auth surface coherent ships in the
box — every region runs its own self-contained Parsec cluster (broker,
manager, DLQ, rate limiter, Redis) and clients connect to their nearest
one.
Tokens issued in us-east must verify in eu-west; a key retired in
one region must stop accepting tokens in every other region within a
second.
Full active-active broker replication (cross-region channel history, conflict resolution, CRDT-Redis) is deferred to v0.4+. This page covers what you can do today.
When NOT to use multi-region
Single-region is almost always simpler. Use it when:
- Your client base is regional (e.g. a US-only app).
- Cross-region latency for the auth surface is acceptable. A US-based client doing one token issue per session can tolerate a 200ms round-trip to a single Parsec cluster.
- You do not yet have a regional Redis fleet. Parsec multi-region assumes each region runs its own Redis (and that you operate one).
Add a second region when:
- p95 connect/subscribe latency is dominated by the trans-oceanic hop. Clients on three continents will feel it.
- You have a regulatory requirement to keep customer traffic local (e.g. EU data residency for EU users).
- You already run a multi-region service mesh and want Parsec to live next to your existing surfaces.
Topology
┌────────────────┐
│ Operator │
│ (you) │
└───────┬────────┘
│ parsec keys retire k-old
│ parsec keys export | scp …
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ us-east │ │ eu-west │ │ ap-south │
│ Parsec │ │ Parsec │ │ Parsec │
│ ↕ │ │ ↕ │ │ ↕ │
│ Redis │ │ Redis │ │ Redis │
└──────────┘ └──────────┘ └──────────┘
▲ ▲ ▲
│ │ │
└─── US clients │ │
EU clients APAC clients
Each region is independent for channels, presence, history, sinks, and
DLQ. The only cross-region coherence point is the keyring — every
region must verify the same kid set so a token minted in us-east
verifies in eu-west.
Configuration
Region tag
Options.Region (or region: in the YAML config) labels every
Prometheus metric and every access-log line with the operator’s region
name. Empty value (single-region default) attaches no label — the
scrape and log shape stays lean for single-region deployments.
region: us-east
peers:
- https://parsec.eu-west.example.com:8000
- https://parsec.ap-south.example.com:8000
peers: is informational — Parsec does not call into peers
automatically. The list surfaces in the manifest so operators (and
tooling like parsec-keys-sync) can discover topology.
Prometheus example query (after configuring two regions):
sum by (region) (rate(parsec_publishes_total[1m]))
Backward compatibility
Existing single-region deployments need no changes. The new fields
default to empty / nil, the manifest omits them (omitempty), and the
metrics scrape stays unlabeled.
Sharing keyrings between regions
Three patterns are supported. Pick based on your network topology.
Pattern 1 — Push via the CLI
The operator generates and promotes a key in region A, exports the ring, and imports it into every peer.
# Region A (the source)
parsec keys generate
parsec keys promote k-abcdef123456
parsec keys export --state-dir /var/lib/parsec > /tmp/keyring.json
# For every peer region
scp /tmp/keyring.json eu-west:/tmp/
ssh eu-west 'parsec keys import --state-dir /var/lib/parsec < /tmp/keyring.json'
For Redis-backed deployments:
parsec keys export --redis-addr us-east-redis:6379 > /tmp/keyring.json
parsec keys import --redis-addr eu-west-redis:6379 < /tmp/keyring.json
keys import calls KeyRingStore.Save which (for Redis) bumps the
version key and publishes the rotation event, so every parsec node in
the target region reloads through its existing watch goroutine.
Collision safety
keys import refuses to overwrite an existing kid whose secret
differs:
PARSEC_INVALID_ARGUMENT: key id "k-abcdef" present locally with a
different secret; pass --force to overwrite
This protects against the “two regions independently generated the
same kid” pathological case. Pass --force only when you intend to
overwrite — for example, when migrating from independently-bootstrapped
regions to a shared ring.
Pattern 2 — Push via the notify hook
When you retire a key, propagate the retirement to every peer in one command:
parsec keys retire k-old \
--notify https://parsec.eu-west.example.com:8000 \
--notify https://parsec.ap-south.example.com:8000
The CLI does the local retire first, then POSTs to each peer’s
/twirp/parsec.ParsecService/ReloadKeys with the operator’s current
bearer (so peers must trust the same kid via a shared keyring — usually
via Pattern 1 first, or via a shared OIDC issuer from
Phase 21).
Individual peer failures log a warning but do not roll back the
local retire — propagation is best-effort. The envelope output
includes a notified array with per-peer status so you can spot which
region drifted.
Pattern 3 — Pull via parsec-keys-sync
Run the standalone parsec-keys-sync daemon next to each region’s
Redis. It subscribes to the source region’s <prefix>:keyring:events
pub/sub channel and re-publishes every payload to every target region’s
Redis.
parsec-keys-sync \
--from us-east-redis:6379 \
--to eu-west-redis:6379 \
--to ap-south-redis:6379 \
--key-prefix parsec
The daemon is stateless and idempotent — re-publishing an already- applied version is a no-op on the target side (the target’s parsec nodes load the snapshot and see no change). Restart the daemon with the same flags after any crash; there’s nothing to recover.
A common deployment is one daemon per source region, each forwarding
to every other region. With three regions you run three daemons, each
with two --to targets.
Operating notes
- The daemon is a separate binary at
cmd/parsec-keys-sync/. Build it from the parsec repo:go build -o bin/parsec-keys-sync ./cmd/parsec-keys-sync. - Logs every forwarded event at INFO. Forwarding failures log at WARN and the daemon keeps running — a flapping target does not affect the others.
- SIGTERM / SIGINT cause a graceful shutdown (cancel context, unwind the subscriber, exit 0).
- The daemon needs network access to all Redis instances. Place it accordingly in your VPC peering / firewall rules.
Push vs Pull — picking a pattern
| Question | Use |
|---|---|
| Do operators have a shell on every region? | Push |
| Do you want a single command to propagate retires? | Push |
| Are regional Redis instances on a shared L7 mesh? | Pull |
| Do you want automated continuous mirroring? | Pull |
| Can you tolerate operator-driven manual propagation? | Push |
The two patterns are complementary. A common combination: bootstrap with Pattern 1 (single export+import on day 1), then run Pattern 3 continuously so rotations propagate automatically.
Latency considerations
- Key rotation propagates at the speed of your slowest link. Pattern 2 (notify) is HTTP RPC over your existing load balancers; expect 50ms to 300ms depending on geography. Pattern 3 (keys-sync) is a Redis publish; same-DC publishes complete in microseconds, trans-oceanic publishes in 100-200ms.
- Token verification is local — the verifying region never reaches across to the issuing region. As long as both regions have the same kid in their ring (via export/import or keys-sync), a token minted in us-east verifies in eu-west with zero cross-region latency.
- Connect / subscribe / publish are entirely region-local. There
is no broker replication; a publish to
public:foo.barin us-east is not seen by subscribers in eu-west. Clients that need cross- region fanout should subscribe to the same logical channel on every region they care about, or wait for v0.4 active-active replication.
Failure modes
| Scenario | Behavior |
|---|---|
| One region’s Parsec is down | Clients re-route to the next closest region (operator’s load balancer / DNS). Tokens still verify there. |
Peer region unreachable during --notify | The local retire still succeeds. The unreachable region keeps verifying the old kid until reachable + reloaded. |
parsec-keys-sync crashes | No data loss — restart with the same flags. Rotations missed during downtime can be triggered manually with parsec keys reload on the target region. |
| Two regions promote different keys | The last import wins. Prefer Pattern 1 (operator-driven) for promotion; promotion is a deliberate operator action, not a rotation event. |
Out of scope
- Active-active broker replication (channel state, history, presence). Deferred to v0.4+.
- DNS-based region routing — handle with your existing load balancer or CDN (GeoDNS, AWS Global Accelerator, Cloudflare Workers, etc.).
- Region failover automation — handle with your existing orchestrator (Kubernetes Service mesh, Consul, etc.).
- Cross-region rate-limit coordination. Each region’s Redis hosts its own rate-limit state; a publisher hitting two regions could in theory exceed the configured rate. For most deployments this is acceptable — the per-region ceiling is still enforced.
Admin UI
Parsec ships an embedded single-page application at /admin for
operator-grade diagnosis. This is not an end-user surface. The UI
is intentionally minimal — vanilla HTML, JavaScript and CSS, no
build pipeline, no frameworks, served from embed.FS.
The admin UI is off by default. Operators opt in per deployment. When disabled the entire
/admin/*tree returns 404, so no bytes from the asset bundle reach the response graph.
Enabling
YAML:
server:
admin_ui:
enabled: true
CLI:
parsec serve --admin-ui
Env:
PARSEC_ADMIN_UI=true parsec serve
The CLI flag overrides the YAML setting when explicitly passed.
Confirming it’s on
Hit /manifest; look for admin_ui_enabled: true:
curl -s http://localhost:8000/manifest | jq '.payload.admin_ui_enabled'
Pages
| Path | Purpose |
|---|---|
/admin/ | Landing page + raw manifest dump |
/admin/login.html | Operator pastes a mgmt bearer; stored in sessionStorage |
/admin/channels.html | List channels; open public; create private; delete |
/admin/keys.html | List keys; generate; promote; retire |
/admin/dlq.html | Sink-scoped DLQ inspection; replay or discard items |
/admin/limits.html | Read-only view of configured rate-limit budgets |
Auth model
The UI is a thin wrapper over the Twirp surface. Every action invokes
the same POST /twirp/parsec.ParsecService/<Method> route a curl
operator would call, with the mgmt bearer attached as
Authorization: Bearer <token>. The bearer lives in sessionStorage
which the browser clears when the tab closes — so leaving the UI
open is identical to leaving a shell with PARSEC_TOKEN exported.
The login form itself is plain static HTML; the Twirp calls enforce
the bearer, not the asset routes. A 401 from any RPC redirects the
page back to login.html and clears the stored token.
Layout sketch
+--------------------------------------------------------------+
| parsec admin |
+--------------------------------------------------------------+
| Channels Keys DLQ Limits sign out |
+--------------------------------------------------------------+
| Operator-grade diagnostic UI. Pick a section above. |
| |
| { |
| "format_version": "1.0", |
| "kind": "parsec.manifest", |
| "payload": { |
| "service": "parsec", |
| "active_key_id": "k-...", |
| "admin_ui_enabled": true, |
| ... |
| } |
| } |
+--------------------------------------------------------------+
Limits
- No WebSocket subscriber view — the UI uses the Twirp surface only.
- No multi-user roles inside the UI. The mgmt bearer IS the role. Want different views for different operators? Mint mgmt tokens with pattern scopes and let the bearer’s grants decide what each call succeeds at.
- No SSO from the UI. Use the OIDC bridge to obtain a mgmt bearer outside the UI and paste it in.
- No history. Refresh-survival of state is not a goal — the UI is a diagnostic, not a console.
When to use it
- A pager fires; you want to see channel state without
curl + jq. - You’re rotating keys and want to confirm the new key landed.
- You suspect a sink is backed up; you want a quick DLQ peek.
- You’re showing parsec to a colleague and screenshots beat shell.
When not to use it:
- Building a UI for application users. Use the websocket transport
- the issued credentials. The admin UI is operator-only.
- Automating any workflow. Use the CLI or RPC directly.
- Production deployments that don’t want the bytes on the wire.
Leave
server.admin_ui.enabledat its defaultfalse.
Troubleshooting
Common error codes and what to do about them.
# When in doubt, bump the log level and re-run the failing command.
parsec serve --addr :8000 --state-dir /var/lib/parsec
# Use the LOG_LEVEL env var (the slog default handler honors it) for
# debug-level structured output.
LOG_LEVEL=debug parsec serve --addr :8000
Every error that crosses a package boundary carries a PARSEC_* code.
The codes are stable; the messages are not. Match on the code.
PARSEC_AUTH_DENIED
The mgmt bearer is missing, malformed, or signed by a key the ring no longer trusts. Causes, in order of likelihood:
- You forgot
--token/PARSEC_TOKEN. Set it and retry. - The token’s
kidresolves to a retired key. Mint a fresh bearer:parsec tokens mgmt. - The token’s
algortypclaim was tampered with. Parsec only acceptsHS256/JWTwith akid. - The keyring was rotated and the old key retired. See key rotation.
Note: a closed channel returns PARSEC_CHANNEL_CLOSED, NOT
PARSEC_AUTH_DENIED. If you see denied on a subscribe, the token is
the problem.
PARSEC_AUTH_EXPIRED
The token’s exp claim is in the past. Mint a new one. For access
tokens, use the refresh flow:
parsec tokens refresh "<refresh-token>"
For mgmt tokens, mint a fresh bearer under the active key:
parsec tokens mgmt --ttl 24h
PARSEC_CHANNEL_INVALID
The name does not match the grammar enforced by
channels.ParseName. See naming. Frequent
gotchas:
- Missing
public:/private:prefix. - Uppercase or non-ASCII components.
- A
:inside the body (the colon is reserved for the visibility prefix). - Private channel without an id segment.
PARSEC_CHANNEL_NOT_FOUND
The channel either:
- Was never opened.
- Was a private channel whose TTL elapsed and was reaped on the next sweep (default sweep interval: 30s).
- Was deleted explicitly.
- Lived in a process that has since restarted (channel state is in-memory).
Reopen public channels with channels open; private channels need to
be re-created and a fresh token pair handed out.
PARSEC_CHANNEL_CLOSED
Public channel whose inactivity TTL elapsed. The record is still there; new subscribes are refused. Reopen:
parsec channels open public:<name> --ttl 1h
This resets LastActive and emits a fresh opened event.
PARSEC_CHANNEL_EXISTS
You called channels create on a private name that already exists.
Private channels do NOT support reopen — the API only goes one way.
Pick a different id segment or wait for the existing channel to
expire.
PARSEC_CHANNEL_TTL_EXCEEDS_MAX
You requested a private channel with ttl > 1h. The cap is in
channels.MaxPrivateTTL. Lower the TTL and retry. If you actually
need a long-lived feed, consider whether it should be public.
PARSEC_BROKER_NOT_READY
The Centrifuge node has not finished node.Run() yet. Almost always a
boot race — the server received an RPC before it finished initializing
the broker. Retry after a short backoff; this usually clears in under
a second.
PARSEC_BROKER_INTERNAL
The OSS Centrifuge library returned an error from Publish,
Presence, or Run. Check the server log; the underlying message is
preserved in the wrapped error chain.
PARSEC_SINK_UNAVAILABLE
A sink’s Send failed at the transport layer. Common causes:
- SMTP server refused the relay (email sink).
- Slack webhook returned a 4xx/5xx.
- Webhook target was unreachable.
Parsec does NOT retry. Wrap the sink in your own retry layer or queue
the message upstream of PublishOrSink.
PARSEC_SINK_CONFIG_INVALID
A sink rejected its constructor config. Re-read the sink’s documentation:
PARSEC_INVALID_ARGUMENT
Generic input validation failure. The accompanying message is the useful part — read the server log or the response body.
PARSEC_INTERNAL
Unhandled, unexpected error path. File a bug with the surrounding log context. These should be rare; treat them as actionable.
Debug tips
- Run
parsec channels listto confirm what the server thinks exists. - Run
parsec channels get <name>to inspect a single record’s state andlast_active. - Use
parsec --json(or curl/manifest) to verify the server version, configured sinks, and persistence stance. - Watch the server log with the slog default handler on debug; key
events (
keyring reload,bootstrap mgmt token) are emitted as structured events.
See also
Development setup
How to get a working Parsec checkout and run the development loop.
git clone https://github.com/frankbardon/parsec.git
cd parsec
make build
make test
Prerequisites
- Go 1.26.1 or newer. The go.mod declares the toolchain; an older compiler will refuse to build.
- A POSIX shell (Linux or macOS).
make. The repo uses GNU make for the development loop.mdbook(optional) — only required formake docs.- No CGO, no protoc by default. The
prototarget needsprotoc,protoc-gen-go, andprotoc-gen-twirponly when you regenerate the RPC bindings.
Verify your Go install:
go version
# go version go1.26.1 darwin/arm64
Clone
git clone https://github.com/frankbardon/parsec.git
cd parsec
The module path is github.com/frankbardon/parsec. The repo follows
the standard Go layout — cmd/ for binaries, internal/ for non-API
packages, every other top-level directory is a public library
package.
Build
make build
# bin/parsec
This runs go build -trimpath -ldflags="-s -w" -o bin/parsec ./cmd/parsec with CGO_ENABLED=0. The Makefile owns the build flags;
the goal is a reproducible, statically linkable artifact.
Confirm the binary:
./bin/parsec --version
# parsec version dev
Test
make test
# go test ./...
The full suite is fast (under a minute on a modern laptop). Per-package
tests are conventional Go tests; the channels manager and parsec
end-to-end suites use injected clocks rather than time.Sleep so the
suite stays deterministic.
For coverage:
make cover
# go test -coverprofile=coverage.out ./...
# go tool cover -func=coverage.out
Lint
make lint
# go vet ./...
# go run honnef.co/go/tools/cmd/staticcheck@latest ./...
make lint chains go vet and staticcheck. The staticcheck call
is via go run so you don’t need to install it separately. All new
code is expected to pass both. If staticcheck flags a lint you
believe is wrong, add a //lint:ignore with a brief rationale rather
than silently muting the rule.
Format
make fmt
# go fmt ./...
Imports are managed by gofmt; do not hand-sort them.
Documentation
make docs
# mdbook build docs
The mdBook source lives under docs/src/. Adding a new page means
adding a file under docs/src/<section>/ AND adding a line to
docs/src/SUMMARY.md. The summary is the table of contents — pages
that exist on disk but are not in the summary are invisible.
For live preview while writing:
make docs-serve
# mdbook serve docs --open
Protobuf (rarely)
make proto
# protoc + protoc-gen-go + protoc-gen-twirp
Only needed when the wire shape in rpc/service.proto changes. The
hand-rolled rpc/types.go is the contract until then.
Submitting changes
- Branch from
main. - One logical change per PR. Keep diffs small.
- Run
make lint test docsbefore opening the PR. The CI runs the same targets. - Update the relevant doc page in the same PR — see “The Update
Demand” in
CLAUDE.mdfor the full mapping.
See also
- Style guide.
- The architecture overview for a package-by-package tour.
Style guide
How to write Go that fits the rest of the codebase.
// Package channels owns the public/private channel namespace and the
// lifecycle manager. Every other Parsec surface defers to ParseName for
// validation.
package channels
Two reflexes summarize most of the rules: keep functions short, and let the type system carry the contract.
Files
- Short. Most files in the repo are under 200 lines. If a file is growing past 300, split it by responsibility.
- Package-doc on every package, on the file named for the package
(
channels/name.gocarries the// Package channels...doc). - One package per directory. No subpackages by hand.
Comments
- Comment what is not obvious. Do not echo the function signature in English.
- Comment every exported symbol with a sentence that starts with its
name, per
gofmtconvention. - Do not narrate the implementation inside a function. If you feel the need to, the function is probably too long.
A bad comment:
// Touch resets the LastActive timestamp on the channel.
func (m *Manager) Touch(name Name) error {
// ...
}
A good comment:
// Touch marks the channel as recently active so the tick loop will not
// expire it on the next sweep.
func (m *Manager) Touch(name Name) error {
// ...
}
The first one is the signature in English; the second carries the intent.
Errors
- All errors that cross a package boundary are
*errors.Errorfromgithub.com/frankbardon/parsec/errors. Nevererrors.New(...)from the stdlib at a boundary. - Codes are
PARSEC_DOMAIN_CATEGORYstrings. Pick the right one fromerrors/codes.go; if none fits, add one in that file in the same PR and update the RPC mapping inrpc/server.go:writeServiceError. - Wrap with
errors.Wrap(code, msg, cause)when you have an underlying error; otherwiseerrors.New(code, msg).
Internal helpers can use any error type — but the moment the error is returned from an exported function, it must be coded.
Channel names
There is exactly one validator: channels.ParseName. Every surface
calls it. Do not reimplement parsing logic anywhere else. If you need
a new visibility prefix or a new component rule, the change lands in
channels/name.go and gains tests in channels/name_test.go.
Library-first
parsec.Parsec is the deliverable. Everything else (CLI, RPC) is a
translator.
cmd/parsec/main.goparses flags. Nothing else.internal/cli/*builds command structures. The bodies dispatch toservice.Serviceor*parsec.Parsec. No business logic.- If a CLI subcommand grows logic that the library cannot also call, the CLI is wrong. Pull the logic into a library package and let the CLI re-use it.
Filesystem access
Library code uses afero.Fs (opts.FS). Never os.Open /
os.ReadFile inside library packages. The CLI is the one allowed
exception (e.g. publish --file).
Descriptor envelopes
Every JSON output (CLI --json, /manifest) wraps its payload in
descriptor.Envelope. The format version bumps on any wire-shape
change.
Tests
- Table-driven where it helps.
channels/name_test.gois the model. - Inject the clock for any time-dependent test.
Manager.SetClockis there for this; do not calltime.Sleep. - Tests live next to the code, in
_test.gofiles. The naming pattern isTestThing_subcase. AvoidTestXfollowed by 30 unrelated scenarios.
Imports
- Group: stdlib, third-party, project-local.
gofmtenforces. - No dot imports.
- No blank imports outside
mainpackages.
Naming
- Package names are lower-case, one word.
- Exported types are
TitleCase. Exported constants are alsoTitleCase(noMAX_FOOshouting). - Receivers are short — a single letter that matches the type
(
m *Manager,b *Broker,p *Parsec). The receiver name is consistent across every method on the type.
Anti-patterns to refuse
These show up in PRs and should be pushed back on:
- A new
internal/package that duplicatesservice/logic. - A CLI command that calls
broker.*orchannels.*directly. Go throughservice.Service. - A new channel-validation helper outside
channels/. - A change to a public surface (CLI, RPC, library) without an update to the corresponding docs page in the same PR.
See also
- Development setup.
- The
CLAUDE.mdat the repo root — the operational contract this guide derives from.