@gomagentic/verdict-sync
v0.1.1
Published
Verdict hybrid-mode sync client: local evaluation over bundles synced from a control plane. ETag polling, SSE, WebSocket, and push transports; signature verification, replay protection, staleness policies, rollback ring.
Maintainers
Readme
@gomagentic/verdict-sync
Hybrid mode: evaluate locally, sync signed policy bundles from a control plane.
Part of Verdict — a serverless-first authorization engine. Policies (RBAC / ABAC / ReBAC) compile once and decide in microseconds, embedded in your app, behind a central PDP, or synced to the edge.
Hybrid mode combines the microsecond local decisions of the embedded engine with centrally-managed policy. A SyncedVerdict keeps a verified, current bundle in memory and evaluates against it; the control plane never sits in the request path. The only failure mode is a stale bundle — never "no policy."
Install
npm install @gomagentic/verdict-syncBuilds on @gomagentic/verdict-engine (the local evaluator) and @gomagentic/verdict-core (types and the StalenessPolicy enum).
Quick start
import { StalenessPolicy } from "@gomagentic/verdict-core";
import {
SyncedVerdict,
HttpBundleSource,
PollingTransport,
} from "@gomagentic/verdict-sync";
const synced = await SyncedVerdict.start({
// Where bundles come from — the PDP's /v1/bundles endpoints.
source: new HttpBundleSource({
baseUrl: "https://pdp.example.com",
token: process.env.VERDICT_TOKEN!, // API key with the `check` scope
}),
// How refreshes are triggered — ETag polling with anti-herd jitter.
transport: new PollingTransport({ intervalMs: 30_000 }),
// Ed25519 verification key (JWK). Only bundles signed under this key are
// accepted; unsigned bundles are refused.
publicKeyJwk: {
kty: "OKP",
crv: "Ed25519",
x: "u5r3lS8oQ9tG5V0m3s6d4...",
},
// fail-closed: deny with STALE_BUNDLE once maxStalenessMs passes without a
// successful sync. Default is fail-static (serve the last good bundle).
stalenessPolicy: StalenessPolicy.FailClosed,
maxStalenessMs: 300_000,
retain: 2, // previous bundles kept for localRollback()
});
// Always local, never waits on sync.
const decision = synced.check({
principal: { id: "u_42", roles: ["employee"], attr: { department: "eng" } },
resource: { kind: "leave", id: "lv_9", attr: { managerId: "u_42" } },
actions: ["approve"],
});
// Same call with a full trace: matched rule, conditions, missing attrs.
synced.explain({ principal: { id: "u_42" }, resource: { kind: "leave", id: "lv_9" }, actions: ["approve"] });SyncedVerdict.start() is fail-fast: it fetches and verifies the initial bundle before returning, and throws if the source has none — a decision point must never come up without policy. After that, check / explain / batchCheck are pure local calls.
You can drive syncing yourself instead of passing a transport — omit it and call synced.refresh() (returns a RefreshOutcome of "swapped" | "unchanged" | "skipped") or the error-safe synced.safeRefresh(). This is the idiomatic pattern on Cloudflare Workers, which have no background timers: serve from the cached engine and ctx.waitUntil(synced.safeRefresh()) per request.
To read directly from object storage with no control plane on the read path, swap the source for a RepositoryBundleSource(repo, tenantId).
Transports
All transports implement the SyncTransport interface (start(handle) / stop()), receive a SyncHandle, and only deliver a hint — the actual state transfer is always the verified source fetch, so a spoofed or duplicated notification costs at most one extra conditional request.
| Transport | Mechanism | Fit |
|---|---|---|
| PollingTransport | Interval + jitter (intervalMs, jitterRatio) over the source's ETag / If-None-Match conditional fetch | Baseline; works on any runtime with timers |
| SseTransport | GET /v1/bundles/watch (text/event-stream), incremental parser, auto-reconnect | Long-lived processes wanting near-instant propagation |
| WebSocketTransport | Factory-injected socket (factory), reconnect on close | Infra that already terminates WebSockets |
| PushTransport | notify() called by your queue consumer; resolves after the sync so consumers can ack | Event-driven fleets (Cloudflare Queues, Kafka, Redis pub/sub) |
Safety
- Ed25519 signature verification. When
publicKeyJwkis set, every incoming envelope is verified before it is applied; tampered, wrongly-signed, or unsigned bundles are rejected — a thrown error at startup, and viaonSyncError(current bundle keeps serving) at refresh. - Replay protection. An envelope whose
bundleVersionis<=the current one is skipped, so a stale or duplicated bundle can never move you backwards. Legitimate control-plane rollbacks mint new versions;allowRegression: trueopts out for emergency repo-level rollback. - Staleness policy.
fail-static(default) serves the last good bundle forever;fail-closeddenies every decision withSTALE_BUNDLEoncemaxStalenessMselapses without a successful sync. A source that reports "no bundle" does not reset the staleness clock. - Rollback ring. The last
retain(default 2) envelopes are kept in memory;localRollback()swaps back to the previous bundle — re-verified on the way in — with no network round trip. - Atomic swap. The replacement engine is fully constructed and verified before the reference flips; in-flight checks finish on the old engine. Concurrent
refresh()calls coalesce onto a single in-flight fetch.
Documentation
License
Apache-2.0
