@quantiya/quorum-core
v1.0.0
Published
Quorum orchestration client SDK — typed TypeScript client for the AppSync-hosted orchestration engine. Drives multi-agent peer review, revision loops, and tamper-evident audit logging.
Downloads
49
Maintainers
Readme
@quantiya/quorum-core
Typed TypeScript client for the Quorum orchestration engine — an AppSync-hosted, multi-agent peer-review service that drives the two-gate review flow: primary agent proposes, peer reviewers vote, consensus resolves or escalates to the user, with a tamper-evident audit log of every decision.
Homepage: quorum.quantiya.ai
Status: prerelease.
1.0.0is the first npm publish (no 2e.4 source ever shipped to the registry). The API below is what the 1.0.0 tarball will expose.
Requirements
- Node 18+ — uses
globalThis.fetchandglobalThis.crypto.subtle, both of which Node 18+ ships natively. Subscriptions need a WebSocket implementation. Node 22+ and modern browsers exposeglobalThis.WebSocketnatively; Node 18/20 do NOT ship a stable globalWebSocket, so callers on those runtimes must passwebSocketFactoryinConnectParams. The factory MUST forward theprotocolsargument — AppSync's realtime endpoint requires thegraphql-wssubprotocol, which the SDK passes viaprotocols; dropping it breaks the subscription handshake. Example:(url, protocols) => new (require("ws"))(url, protocols). Without a factory,client.onGateResolved(...)throws a typedEngineEvalErrorwithpayload.kind === "client_error"pointing atwebSocketFactory. No third-party crypto, HTTP, or GraphQL deps; no AWS SDK, no Amplify. - An AppSync HTTPS endpoint URL (the 2.0 backend's
GraphQLApiUrlCloudFormation output) and a Cognito ID token (JWT) for the signed-in user. Token refresh is the caller's responsibility. - A pre-derived 32-byte AES-256-GCM session key for the session
this client is bound to. The session key is decrypted from
Session.encryptedKeys[deviceId]using the caller's ECDH private key — that pipeline already lives in@quantiya/codevibe-coreundersrc/crypto/.
The SDK is a thin client. It does not spawn reviewer subprocesses,
manage device keys, or own session lifecycle — those are
@quantiya/codevibe-core's job. Reviewer fan-out happens in-process on
the caller side via @quantiya/codevibe-core/reviewer (see
Reviewer dispatch below).
Quick start
import { randomUUID } from "node:crypto";
import {
OrchestrationClient,
type ResolvedGateEvent,
} from "@quantiya/quorum-core";
const client = await OrchestrationClient.connect({
appsyncUrl: "https://abc.appsync-api.us-east-1.amazonaws.com/graphql",
cognitoIdToken: cognitoSession.idToken,
tokenProvider: async () => (await cognitoSession.refresh()).idToken,
sessionId, // Session.sessionId
sessionKeyB64, // 32-byte AES key, base64-encoded
});
try {
// Step 1: prepare. Same idempotencyKey on retries — the SDK
// auto-retries transient network errors (§7.4).
const prepared = await client.prepareProposalGate({
rawAgentOutput: agentResponse,
taskId,
roundNumber: 0,
triggeredBy: "initial",
prepareIdempotencyKey: randomUUID(),
});
if (prepared.kind === "resolved") {
// Parser fast-path — outcome already in hand, no fan-out needed.
handleOutcome(prepared.outcome);
return;
}
// Step 2: caller dispatches reviewers in parallel. Use
// `@quantiya/codevibe-core/reviewer` to spawn the real CLIs;
// see Appendix B of the design for the FanOutRequest →
// ReviewerSpec adapter.
const verdicts = await Promise.all(
prepared.fanOutRequests.map((req) => dispatchReviewer(req, prepared.gateId)),
);
// Step 3: submit each verdict. The mutation response is the
// AUTHORITATIVE outcome signal — onGateResolved is supplementary.
const responses = await Promise.all(
verdicts.map((v) =>
client.submitReviewerVerdict({
gateId: prepared.gateId,
seatId: v.seat_id,
verdict: v,
}),
),
);
// ONE OR MORE responses may carry kind: "gate1Resolved" — concurrent
// submitters can all land in the resolved branch via the lost-race
// idempotent path (§5.3 / Appendix B).
const resolved = responses.find(
(r) => r.kind === "gate1Resolved" || r.kind === "gate2Resolved",
);
if (resolved && resolved.kind === "gate1Resolved") {
handleOutcome(resolved.outcome);
}
} finally {
await client.destroy();
}A complete worked example — including the reviewer-dispatch adapter,
parallel-submit dedupe, and the optional observer subscription — is
the Appendix B sample in
codevibe-backend/docs/2.0/PHASE-2F-5-DESIGN.md.
OrchestrationClient.connect(params)
connect() is async, but does no network I/O — the client is
constructed synchronously and the returned Promise resolves on the
next microtask. Network-resolved failures (wrong URL, wrong region,
expired JWT) surface on the first real call, not at connect
time, so plugin startup at scale won't bottleneck on a mandatory
health-ping.
A malformed or non-32-byte sessionKeyB64 rejects the
connect() Promise before any network I/O with a typed
EngineEvalError whose payload.kind === "encryption_failed" —
connect() returns a Promise (it's async), so the failure is a
rejection rather than a synchronous throw, but it's still
deterministic and immediate. The SDK can't sensibly defer the check,
since every method needs the key. Catch this if sessionKeyB64 is
sourced from caller-controlled state.
Note on base64 validity: under Node, Buffer.from(b64, "base64") is
permissive — it may silently accept non-canonical input as long as
the decoded byte length is correct. So the trigger for the
rejection is the decoded byte length not equaling 32, not strict
base64 well-formedness; pass canonical base64 if you need strict
validation, or run a pre-check in caller code.
interface ConnectParams {
/** AppSync HTTPS endpoint URL (the GraphQLApiUrl output). */
appsyncUrl: string;
/**
* Cognito ID token (JWT). Caller refreshes; the SDK passes the
* current value as `Authorization` on every request.
*/
cognitoIdToken: string;
/**
* Optional: function returning the current ID token. Called per
* request so a long-lived client survives token rotation. If
* omitted, `cognitoIdToken` is used statically.
*/
tokenProvider?: () => Promise<string>;
/** Session this client is bound to. One client = one session. */
sessionId: string; // UUID
/**
* 32-byte AES-256-GCM key for the session, base64-encoded. Held
* in a `SessionKeyHandle` opaque wrapper internally; zero-filled
* (best-effort) on `destroy()`.
*/
sessionKeyB64: string;
/**
* Optional key version (matches `EncryptedPayload.keyVersion`).
* Bumped when the caller rotates session keys. Absent → Lambda
* treats as v1.
*/
keyVersion?: number;
/**
* Optional WebSocket factory. Default uses `globalThis.WebSocket`.
* Pass a custom factory in environments without one (e.g. older
* Node bundles before the global landed).
*/
webSocketFactory?: (url: string, protocols?: string[]) => WebSocket;
}Optional health probe
If you do want a connect-time round-trip:
await client.ping();
// Internally: queryAudit on a sentinel taskId, swallowing
// task_not_found. Throws on auth or routing failures.Cleanup
await client.destroy();
// Closes the subscription WebSocket, drops the bound session key,
// zero-fills the in-memory key buffer (best-effort — see §4.4 of
// the design on JS GC limits). Subsequent calls throw
// `EngineEvalError.client_destroyed`.API surface
Every method takes a typed parameter object and returns a typed result or discriminated union. There are no JSON strings exposed at the surface — the SDK transforms wire shapes into TypeScript-ergonomic form on the way out.
| Method | Purpose |
|---|---|
| prepareProposalGate(params) | Open Gate 1. Returns either a Resolved fast-path outcome (parser short-circuit) or a Pending handle plus the fan-out requests. Encrypts rawAgentOutput. |
| prepareCompletionGate(params) | Open Gate 2. Encrypts proposal, artifact, optional progressEvents. |
| submitReviewerVerdict(params) | Submit a single seat's verdict. Encrypts verdict. Response is gate1Resolved / gate2Resolved / acknowledged. |
| applyUserDecision(params) | Dispatch the user's choice at an escalated gate. Encrypts optional notes. |
| recordExecutionEvent(params) | Append a host-emitted audit entry (progress_event, tool_use, etc.). Encrypts payload. |
| flushAudit() | Force-flush buffered audit entries server-side. Takes no arguments — the session is bound at connect() time. |
| queryAudit(taskId) | Read every audit entry for a task. Payloads remain ciphertext — see Decrypting audit payloads. |
| onGateResolved(callback) | Subscribe to gate1Resolved / gate2Resolved events on this session. SDK filters out kind: "acknowledged" server pings before your callback fires. Returns an unsubscribe(). |
Method-by-method wire mapping, error semantics, and idempotency rules are in §5 / §7 of the design doc.
Reviewer dispatch
The SDK does not spawn reviewer subprocesses. After a successful
prepareProposalGate / prepareCompletionGate returns a Pending
handle, the caller dispatches each FanOutRequestWire itself —
typically through @quantiya/codevibe-core/reviewer's
createSubprocessReviewerRegistry().
The Lambda's Rust adapter wraps each FanOutRequest into a
ReviewerSpec with locked defaults; the TS SDK caller MUST match
those defaults so backend audit / gate-policy guarantees hold:
// Match codevibe-engine/src/orchestration.rs:581,587
const REVIEWER_TOOL_ALLOWLIST = ["Read", "Grep", "Glob"] as const;
const REVIEWER_TIMEOUT_MS = 30_000;A drift here (relaxed allowlist, longer timeout) silently breaks the
read-only-reviewer invariant or the gate timeout budget. The full
adapter — including the model_hint passthrough and the seat_id /
role / agent field mapping — is the fanOutRequestToReviewerSpec
helper in Appendix B of the design.
Per-session E2E
Seven request fields are encrypted client-side before they reach the
wire (rawAgentOutput, proposal, artifact, progressEvents,
verdict, notes, payload). The SDK encrypts with AES-256-GCM
under the bound session key, using a fresh random nonce per call,
and emits the inline-nonce envelope:
ciphertextB64 = base64( nonce(12) || ciphertext(N) || authTag(16) )
nonceB64 absentThis is byte-for-byte the format produced by
@quantiya/codevibe-core/src/crypto/crypto-service.ts:encryptString
and accepted by the Lambda's crypto::decrypt_payload. A cross-impl
parity gate (§11.2 of the design) keeps Rust and TS aligned.
Per-field size caps
| Field | Max ciphertextB64 bytes |
|---|---|
| rawAgentOutput | 180 KB |
| proposal | 100 KB |
| artifact | 180 KB |
| progressEvents | 100 KB |
| verdict | 50 KB |
| notes | 16 KB |
| payload | 50 KB |
The SDK enforces these post-encryption — over-cap inputs throw
EngineEvalError.payload_too_large locally before any network call.
The exact bytes ship as CIPHERTEXT_B64_MAX_BYTES for reference.
SessionKeyHandle
Wraps the 32-byte session key in an opaque object. JS has no RAII /
Drop — once a Uint8Array is GC'd, clearing is a runtime detail,
and any base64 round-trip leaves a copy in the V8 string heap with
no zeroable handle. SessionKeyHandle exposes an explicit
destroy() that zero-fills the underlying bytes immediately, on a
best-effort basis. client.destroy() calls it for you.
Decrypting audit payloads
queryAudit returns entries with the encrypted payload field
intact — payloads are session-bound ciphertext written by the
engine's audit emit paths. To read them back:
import { decryptAuditPayload, type SessionKeyHandle } from "@quantiya/quorum-core";
const entries = await client.queryAudit(taskId);
for (const entry of entries) {
if (entry.payload?.ciphertextB64) {
const plaintext = await decryptAuditPayload(entry, sessionKey);
// plaintext is the JSON string the engine emitted
}
}decryptAuditPayload accepts both inline-nonce and detached-nonce
envelopes, since the audit emit path predates the 2f.5 inline-only
lock and may have written either shape.
Audit-key helpers (separate package)
recordExecutionEvent requires a 64-char lowercase hex
dedupKeyHex so Lambda retries / DLQ replays don't produce
duplicate audit entries (Phase 2f.2's locked dedup-key taxonomy).
Computing the right hash for each kind is non-trivial — the formulas
are in §5.2 of the Phase 2f.2 design.
To avoid drift, the helpers ship in @quantiya/codevibe-core, not
here:
import { AuditKeys } from "@quantiya/codevibe-core";
await client.recordExecutionEvent({
taskId,
kind: "progress_event",
payload: { step: "build", status: "started" },
dedupKeyHex: AuditKeys.dedupKeyForProgressEvent(taskId, "evt-1"),
});@quantiya/codevibe-core exposes the helpers as the AuditKeys
namespace from the package's main entry — no subpath import needed,
matching the existing Reviewer namespace pattern.
A parity test in @quantiya/codevibe-core pins the helpers' output
byte-for-byte against the Rust codevibe-engine::audit_keys::*
fixtures — drift on either side breaks both sides at CI.
Errors
All SDK errors derive from QuorumError. Two concrete subclasses
match the wire taxonomy:
EngineInitError— reserved forconnect()/ping()failures whose.payload.kindis one ofwrong_endpoint,auth, ornetwork. The lock #3 SDK does no network I/O atconnect()time, so today this is only thrown byping(); first-call failures surface asEngineEvalError.EngineEvalError— thrown from every other method..payload.kindnarrows to one of the documented buckets:client_error— bad input (UUID shape, enum value, JSON-stringify failure on caller-supplied verdict / payload, missingwebSocketFactoryon Node 18/20, etc.)payload_too_large— local preflight or server-side rejection; enriched withfield/sizeBytes/maxBytesunsupported_key_version— caller'skeyVersionahead of what the Lambda acceptsunauthorized— auth header rejectedtier_not_permitted— caller's tier disallows this gatesession_orchestration_disabled— session toggle is offidentity_rejected— caller is not the session ownersession_error— session not in a valid statecrypto_error— server-side decrypt failedplaintext_not_utf8/plaintext_json_invalid— server-side plaintext-validation failureaudit_error/engine_error/storage_error/internal— server-side failuresresource_not_found/task_not_found/gate_not_foundnot_implemented- SDK-synthesized:
network(withtransient: true|false),auth(withreason: "expired" | "missing" | "rejected"),encryption_failed,client_destroyed
Discriminator details live on err.payload (the kind tag is at
err.payload.kind; enriched fields like field / sizeBytes /
maxBytes on payload_too_large, reason on auth, and
transient on network are also on err.payload). Switch on
err.payload.kind for exhaustive handling. Server-emitted envelopes
are normalized through envelopeToErrorKind / envelopeToEvalError;
you generally don't need to call them directly.
import { EngineEvalError } from "@quantiya/quorum-core";
try {
await client.prepareProposalGate({ ... });
} catch (err) {
if (err instanceof EngineEvalError) {
switch (err.payload.kind) {
case "payload_too_large":
console.error(
`field ${err.payload.field} too large: ` +
`${err.payload.sizeBytes} > ${err.payload.maxBytes}`,
);
break;
case "auth":
if (err.payload.reason === "expired") await refreshAndRetry();
break;
case "network":
if (err.payload.transient) await backoffAndRetry();
else throw err;
break;
// …
}
} else {
throw err;
}
}The SDK auto-retries network errors with transient: true for the
five idempotency-keyed methods (prepareProposalGate,
prepareCompletionGate, submitReviewerVerdict,
applyUserDecision, recordExecutionEvent). Three attempts with
500ms / 2s / 8s backoff. All other errors surface immediately.
Subscriptions
const unsubscribe = await client.onGateResolved((event: ResolvedGateEvent) => {
// event.outcome is the same Gate1Outcome / Gate2Outcome you'd get
// back from a submit response.
console.log("observed", event.gateId, event.outcome);
});
// Later:
await unsubscribe();The subscription connects via graphql-ws over WebSocket against
the AppSync realtime endpoint. Two-phase reconnect (urgent
exponential backoff for 10 attempts, then persistent 5-minute retry
indefinitely) — same shape as @quantiya/codevibe-core's
AppSyncClient.
kind: "acknowledged" server pings are filtered out before your
callback fires; you only see real gate resolutions.
The mutation-response path is authoritative for your own
submission's outcome. Use onGateResolved as an observer for UI
that watches gate resolutions across multiple sessions or out-of-band
state changes — never as the primary outcome signal for your own
submit.
Types
Full TypeScript declarations ship with the package — every parameter
object, return value, discriminated union, and enum literal is
typed. dist/types.d.ts is the authoritative reference.
Key types to know:
ConnectParams— whatconnect()takesPrepareProposalGateOutput/PrepareCompletionGateOutput— discriminated unions onkind: "resolved" | "pending"Gate1Outcome/Gate2Outcome— evaluation resultsGateDecision/EscalateReason/PostDecisionAction— discriminated unions for caller logicSubmitReviewerVerdictPayload—gate1Resolved/gate2Resolved/acknowledgedResolvedGateEvent— what the subscription callback receivesFanOutRequestWire— engine-snake_case shape passed back fromprepare*for caller-side dispatchRawAuditEntryWire— whatqueryAuditreturns
Every discriminated union uses kind as its tag.
Development
cd packages/quorum-core
npm install
npm run build # rm -rf dist && tsc
npm test # vitest — wire-format + contract layersThree test layers:
- Layer 1 —
__tests__/wire-format.test.ts: encrypt-then-decrypt parity, error-envelope detection, every method's params shape. Pure offline. - Layer 2 —
__tests__/contract.test.ts: mock-fetch + mock-WebSocket. Exercises happy-path, retry, every error mapping in §7.3, the acknowledged-filter, idempotency-key passthrough, and the destroy lifecycle. - Layer 3 —
integration/smoke.test.ts: opt-in. Hits the dev-stack endpoint with a service-account Cognito user. Skipped unlessRUN_INTEGRATION=1and the fourQUORUM_DEV_*env vars are set.
RUN_INTEGRATION=1 \
QUORUM_DEV_APPSYNC_URL=... \
QUORUM_DEV_COGNITO_TOKEN=... \
QUORUM_DEV_SESSION_ID=... \
QUORUM_DEV_SESSION_KEY_B64=... \
npm test -- integration/License
MIT
