Skip to content
Registry StackDocsDevelopment (unreleased)

Registry Stack client API reference

View as Markdown

One package carries every maintained Registry Stack client API: @registrystack/client on npm and registry-stack-client on PyPI. It holds one namespace per product: discovery for Registry Discovery, evidence for Registry Evidence, breg for Base Registry Engine (BReg), casework for Registry Casework, messaging for Registry Messaging, and scheduling for Registry Scheduling. Each namespace keeps its own routes, authentication, errors, and verification rules, because those contracts differ.

Rust is the canonical implementation. The Python and Node packages are thin bindings over it: they convert native values, construct the Rust client, run one method, and map the validated result or error. They implement no Python or JavaScript HTTP, route, authentication, Problem, redirect, retry, cache, or pagination policy.

Registry Stack is pre-1.0 Beta software. Keep a client on the same Registry Stack release as the deployment it calls unless a release note states a wider compatibility range.

The unified packages are published beginning with Registry Stack v0.26.1. Releases through v0.26.0 carry no Base Registry Engine client package and no unified package; they distribute one standalone package per product instead. The casework namespace is present in those same unified packages beginning with Registry Stack v0.30.0; the messaging namespace is present beginning with Registry Stack v0.38.0; the scheduling namespace is present beginning with Registry Stack v0.40.0. Python requires version 3.11 or later and the wheels use the Python 3.11 stable ABI (abi3-py311), so a later Python imports the same file. Node requires version 24.11 or later. Both packages select a native build for Linux amd64 with glibc, Linux arm64 with glibc, or macOS arm64, and require glibc 2.17 or newer on Linux. The version-specific GitHub Release retains the exact wheel and npm tarballs for an offline install.

The Rust crates have publish = false, stay source-only in the Registry Stack workspace, and are not published to crates.io.

Read the version from the deployment’s binary rather than typing it, then install that exact version:

Terminal window
python -m pip install "registry-stack-client==<version>"
Terminal window
npm install "@registrystack/client@<version>"

The distribution installs as registry-stack-client and imports as registry_client. The two spellings differ, so import registry_stack_client raises ModuleNotFoundError.

A TypeScript consumer also needs the Node type definitions, and must name them. The published declarations use Buffer, a Node.js global, and TypeScript 6 does not load installed @types packages on its own, so a project compiling against the declarations reports TS2591 on every use of Buffer until it has both installed the package and listed it in compilerOptions.types:

Terminal window
npm install --save-dev "@types/node"
{
"compilerOptions": {
"types": ["node"]
}
}

The package does not declare that dependency itself, because a JavaScript consumer does not need it.

A Rust application takes the source crate from the matching release tag. registry-stack-client is the facade that re-exports the product clients under breg, casework, discovery, evidence, messaging, and scheduling modules; a single-product application can depend on that product’s crate directly instead.

[dependencies]
registry-stack-client = { git = "https://github.com/registrystack/registry-stack", tag = "v<version>" }
from registry_client import breg, casework, discovery, evidence, messaging, scheduling
const { breg, casework, discovery, evidence, messaging, scheduling } = require('@registrystack/client');
use registry_stack_client::{breg, casework, discovery, evidence, messaging, scheduling};

The unified packages carry no Registry Coordinator namespace. Its client is the registry-coordinator-client Rust crate, with Node.js and Python bindings built from source in the same repository. The Registry Coordinator API reference describes the routes it reaches.

Import only the namespaces an application uses. Nothing crosses a namespace, and a Discovery selection is inert metadata that grants no access.

The naming convention is fixed across the bindings:

  • Rust methods that perform an exchange are asynchronous.
  • Python uses the Rust method and option names in snake case, takes options as keyword arguments, and is synchronous. Each method releases the global interpreter lock while a private Tokio runtime performs the exchange, so other Python threads keep running. Nothing here crosses into asyncio.
  • Node uses the camel case form, takes read options as one object, and returns a promise from every method that performs an exchange. A method that performs none returns its value directly.
  • Reads return language-native values: dicts, lists, and bytes in Python; plain objects, arrays, and Buffer in Node. No wrapper type is needed to read a result.
  • Timeouts are seconds in Python and milliseconds in Node. trusted_root_certificates accepts a PEM bundle as bytes in Python; trustedRootCertificates accepts it as a string in Node, except in the Discovery namespace, where it is a Buffer.

A method that sends an already-prepared request performs at most one service exchange, except for the same-key retry of a keyed mutation. A configured private-key-JWT provider can make a separate token-endpoint exchange when it needs to acquire or refresh a bearer. No client follows redirects, reads ambient proxy configuration, fetches referenced schemas, or advances pagination on its own, and none retries any other exchange.

The Evidence client’s profile-driven methods are the exception. request, prepare_progressive, contracts_candidate, and refresh_metadata assemble or refresh a cached service snapshot first, which can cost a protected-resource metadata fetch, an authorization-server metadata fetch, and, for the https-discovery and local-loopback-discovery trust profiles, a service JWKS fetch. request, prepare_progressive, and contracts_candidate also fetch the published definitions catalog when the profile’s contracts are published rather than a reviewed file. A cached, unexpired snapshot skips all of it.

Base URLs may carry a deployment prefix. HTTPS is required except for loopback HTTP, and the URL cannot contain credentials, a query, a fragment, or ambiguous empty path segments.

A 5xx answer may follow a commit. A BReg, Casework, Messaging, or Scheduling mutation that carries an idempotency key has an unknown outcome after every 5xx answer, after a timeout or a broken exchange once the request was sent, and after a protocol failure: a 3xx answer, or an answer that is oversized, cannot be parsed, or does not meet the contract. Its outcome is known after a refusal before sending, a connection that was never established (a connect timeout or a TLS failure included), and every typed 4xx refusal, such as a 429 limit or an idempotency conflict. A credential the BReg token provider could not supply is known too: the client acquires it once, before the first send. BReg’s 410 idempotency.expired is a known refusal as well: the first attempt committed, and only its held response is gone.

Two typed BReg 500 failures are known outcomes too: action.handler_failed and statistical_dataset.domain_violation. The engine returns them only with the attempt rolled back, and the same request fails the same way, so the client never resends them. Every other 5xx answer stays unknown.

The client resends the identical request, with the same key, headers, and body bytes, after a 5xx answer with an unknown outcome, a timeout, or a broken exchange: at most twice by default, waiting 250 ms and then 500 ms, or the service’s Retry-After when that is longer and at most 5 seconds. A longer requested wait, or a Retry-After that is not one delta-seconds value, ends the retries. A protocol failure is resent only when its answer carried a 5xx status, and nothing is resent after a 4xx status line, even when the rest of that answer could not be read. Each attempt has the full request timeout. Reads, and operations that carry no idempotency key, are never resent.

In Rust, is_outcome_unknown() on the client error reports the classification, and with_max_mutation_retries on the client configuration sets the count from 0, which sends once, to 2. The Python and Node clients run the Rust client and apply the same rule. Python takes the count as the max_mutation_retries constructor keyword and Node as the maxMutationRetries config member; a value outside 0 to 2 is a configuration error. The Python error’s outcome_unknown attribute and the Node error’s outcomeUnknown property report the classification as a boolean.

When the returned error still has an unknown outcome, the safe recovery is the same request with the same key, sent within the service’s receipt horizon: the service replays or settles it. A new key can apply the mutation twice. A refusal that answers a resend does not prove the earlier attempt left no effect, so the client returns the earlier unknown-outcome error instead of that refusal.

The receipt horizon is how long a service holds a key’s first answer: BReg’s retention.receiptRetentionDays, 7 days by default, Messaging’s retention.receiptRetentionDays, and Scheduling’s retention.receiptRetentionDays. Every service answers a spent key by one rule: a different request under it is an idempotency conflict, 409 idempotency.conflict in BReg and 409 idempotency.key-reused in the others, whatever the key’s age. Past the horizon the same request is refused with 410 idempotency.expired and the key stays spent. In BReg that 410 means the first attempt committed: read the record or its history rather than sending the write again under a new key. In Casework it means an earlier attempt under the key committed and retention erased only its stored response: reconcile by reading the review request, task, or Directory before choosing a new key. In Messaging, once retention deletes the message record after retention.recordRetentionDays, the request hash goes with it and any request under the key is refused with 410.

In Scheduling, the keyed commands are hold, appointment create, reschedule, and cancel. Releasing a hold carries no key and is never resent.

In Casework, every keyed review-task and Directory mutation is resent, and the error’s mutation_class() is Ambiguous exactly when is_outcome_unknown() holds. Review request create and cancel carry a key but go through the review client’s single exchange and are never resent. Grant revocation and the caseload and clock previews carry no key and are never resent either. Casework answers every 503 with Retry-After: 5, so a resend after one waits 5 seconds.

The Discovery client searches a published index, resolves a requirement to Evidence Types, and records an inert exact selection. It is not a trust broker: a selection carries catalog metadata and proves nothing about origin authenticity, catalog currentness, authorization, or adopter trust. Endpoint trust and the call that follows stay with the relying application.

use registry_discovery_client::{DiscoveryClient, DiscoveryClientConfig};
use url::Url;
let config = DiscoveryClientConfig::new(Url::parse("https://discovery.example.invalid/")?);
let client = DiscoveryClient::new(config)?;
from registry_client import discovery
client = discovery.DiscoveryClient(
base_url="https://discovery.example.invalid/",
request_timeout_seconds=30,
connect_timeout_seconds=10,
maximum_response_bytes=8 * 1024 * 1024,
)
const { discovery } = require('@registrystack/client');
const client = new discovery.DiscoveryClient({
baseUrl: 'https://discovery.example.invalid/',
requestTimeoutMilliseconds: 30_000,
connectTimeoutMilliseconds: 10_000,
maximumResponseBytes: 8 * 1024 * 1024,
});

Node also accepts the base URL as a bare string. The client sends no credentials: a Discovery index is public by construction, so there is no authorization option to set.

DiscoveryClientConfig provides with_request_timeout, with_connect_timeout, with_maximum_response_bytes, and with_trusted_root_certificates.

OperationRustPythonNode
Resolve a requirement to Evidence Typesresolve_evidence_typesresolve_evidence_typesresolveEvidenceTypes
Search the catalogsearch_servicessearch_servicessearchServices
Search Evidence servicessearch_evidence_servicessearch_evidence_servicessearchEvidenceServices

Only those four operations reach the network. Every selection below is local and offline.

A search returns ServiceSearchResponse, holding a catalog_revision and the matched ServiceRecord items. Selecting turns exactly one record into a selection bound to the capability that matched it.

SelectionRustPython and Node
One record by identifier and capabilityresponse.select_exact(request)select_exact and selectExact
One Registry Evidence serviceresponse.select_evidence(request)select_evidence_service and selectEvidenceService
One Evidence Type mapping alternativeresponse.select_alternative(id)select_evidence_alternative and selectEvidenceAlternative

Rust supplies the selections as extension traits on the response, ServiceSearchSelectionExt and EvidenceTypeResolveSelectionExt. Python and Node expose the same four both as client methods and as module functions, so an application that stored a response can select from it without holding a client.

Three local operations act on a stored selection in Python and Node:

  • validate_selection_structure and validateSelectionStructure check closed shape and capability binding, and nothing else. Structural validation grants no trust.
  • accept_selection and acceptSelection apply an adopter-owned local policy callback and return an AcceptedServiceSelection carrying endpoint_url or endpointUrl. A callback that returns false raises kind local-acceptance-refused.
  • renew_unchanged_selection and renewUnchangedSelection return a freshly reselected service only when its trust-relevant semantics are unchanged, and raise kind selection-changed otherwise. The current selection must come from a new online lookup.

Rust names the same three validate_service_selection_structure, accept_service_selection, and renew_unchanged_service_selection.

Python raises discovery.DiscoveryClientError and Node throws the same named class. Both carry kind plus status, problem, and the transport subcategory (transport_kind in Python, transportKind in Node) when the failure carries them. kind is one of configuration, query, no-matching-service, ambiguous-selection, no-matching-alternative, ambiguous-alternative, capability-mismatch, local-acceptance-refused, selection-changed, transport, problem, protocol, and client. Rust returns the matching DiscoveryClientError variants.

For kind problem, problem is one of invalid-request, not-found, result-bound-exceeded, unavailable, or unknown.

The Evidence client requests minimum-disclosure assertions over the public Evidence HTTP contract and verifies every response. Verification is the point of the client: a client built directly with EvidenceClientConfig::new, or read from_profile with the pinned-jwks trust profile, verifies against only a key set the caller pinned out of band; the deployment’s own published key set is never fetched to judge a response it also produced. A profile using the default https-discovery trust (or local-loopback-discovery) instead fetches that published key set itself before verifying against it.

trusted_jwks and revoked_key_ids are mandatory trust inputs pinned out of band. A key set or revoked-key list the verifier could never use is refused at construction.

use std::sync::Arc;
use registry_evidence_client::{EvidenceClient, EvidenceClientConfig, StaticToken};
use url::Url;
let config = EvidenceClientConfig::new(
Url::parse("https://evidence.example.invalid/")?,
Arc::new(StaticToken::new(token)?),
trusted_jwks,
Vec::new(),
);
let client = EvidenceClient::new(config)?;
from registry_client import evidence
client = evidence.EvidenceClient(
base_url="https://evidence.example.invalid/",
trusted_jwks=trusted_jwks,
revoked_key_ids=[],
token=token,
request_timeout_seconds=30,
connect_timeout_seconds=10,
)
const { evidence } = require('@registrystack/client');
const client = new evidence.EvidenceClient({
baseUrl: 'https://evidence.example.invalid/',
trustedJwks: trustedJwks,
revokedKeyIds: [],
token: { static: token },
requestTimeoutMs: 30_000,
connectTimeoutMs: 10_000,
});

maxResponseBytes and max_response_bytes bound the signed response send reads. maxMetadataBytes and max_metadata_bytes bound the documents discover and fetch_jwks read, which are neither signed nor verified, and are a separate decision. user_agent and userAgent replace the default; trusted_root_certificates and trustedRootCertificates add trust roots for the deployment.

token names exactly one credential source. A configuration naming two is refused, so merging two authentication settings cannot silently pick one. The two bindings spell the choice differently, and this is the one place in the package where they do:

SourcePythonNode
A fixed bearer, sent unchangedthe bearer as a plain string{ static: token }
A private-key-JWT client-credentials exchange{"private_key_jwt": {...}}{ privateKeyJwt: {...} }

A Python token that is neither a string nor an object carrying exactly the one key private_key_jwt is refused at construction with kind configuration. Note that this differs from the authorization option of the BReg namespace, which names the static case explicitly in both languages.

The private-key-JWT configuration requires token_endpoint, client_id, and client_key (Node: tokenEndpoint, clientId, clientKey). Its optional members in Python are audience, resource, scopes, assertion_lifetime_seconds, refresh_margin_seconds, request_timeout_seconds, connect_timeout_seconds, and user_agent. Node takes the same eight in camel case, with requestTimeoutMs and connectTimeoutMs for the timeouts, and adds string-valued trustedRootCertificates for the token endpoint. Python has no nested trust anchor for the token endpoint: its top-level trusted_root_certificates crosses as genuine bytes and the nested configuration crosses as JSON, which carries no byte values. client_key holds the private half; it stays in the process and reaches nothing but the token endpoint. Rust passes an Arc<dyn TokenProvider> instead, and can implement a custom provider.

The built-in exchange sends grant_type, client_id, client_assertion_type, and client_assertion, adds scope when scopes is set, and adds the RFC 8707 resource when resource is set. It sends no other form member. Use a pre-acquired short-lived static bearer when an issuer requires one.

from_profile and fromProfile read an application-owned Evidence client profile, which carries the base URL, a trust configuration, a contracts configuration, and the credential configuration in one file; each is a reference to material, never the material itself. The profile is the owner-only JSON document evidencectl client profile create --output writes. The trust configuration decides whether the deployment’s published key set is fetched: the default https-discovery trust (and local-loopback-discovery) fetches and verifies against it, while pinned-jwks instead points to a reviewed local key set file, so nothing is fetched. The optional second argument is a private JWK held in memory, for an application that reads its key from a secret manager rather than from disk. It is never retained by the wrapper, and profile paths and file-backed secrets are redacted from every error.

client = evidence.EvidenceClient.from_profile("evidence-client.json")
const client = evidence.EvidenceClient.fromProfile('evidence-client.json');

Rust names the same entry points EvidenceClient::from_profile, EvidenceClient::from_profile_with_key, EvidenceClient::from_profile_path, and EvidenceClient::from_profile_path_with_key.

The three-step path closes what an application expects before anything is sent, spends that expectation on one exchange, and judges the bytes that come back:

StepRust and PythonNodeReturns
Close the expectations and generate a nonceprepare(spec)prepare(spec)PreparedEvidenceRequest
Send the prepared requestsend(prepared)send(prepared)RawEvidenceResponse
Verify the response against the requestverify(prepared, response)verify(prepared, response)VerifiedEvidence
Send and verify in one callrequest_and_verify(prepared)requestAndVerify(prepared)VerifiedEvidence
Verify at a stated instantverify_as_of(prepared, response, at)verifyAsOf(prepared, response, atMillis)VerifiedEvidence
Read the published definitionsdiscover()discover()The definitions document
Read the published key setfetch_jwks()fetchJwks()A JWKS document
Refresh cached deployment metadatarefresh_metadata()refreshMetadata()Nothing

prepare performs no input or output. The request it returns is good for exactly one exchange: spend it with send or request_and_verify. Python takes the instant for verify_as_of as Unix seconds; Node takes asOfMillis as Unix milliseconds.

The batch forms take the same shape one level up: prepare_batch, send_batch, verify_batch, request_and_verify_batch, and verify_batch_as_of (camel case in Node). A verified batch carries items, each {"status": "available", "verified": ...} or {"status": "not_available"} (Node: notAvailable). SdJwtVcBatchResponse reads the issuance envelope answering a request that presented several holder keys: credentials in the order the request presented them, count, and credential_for_holder_key(index) or credentialForHolderKey(index). Reading the envelope judges nothing; each credential is verified individually.

A VerifiedEvidence carries evidence (the verified payload, field for field), the trace identifier, and pinned_subject_expectations or pinnedSubjectExpectations. Persist those bindings after a first-use acceptance and pass them back in the next request specification. Python spells the specification member subject_expectations, taking either "accept_first_use" or the sequence of role bindings itself. Node spells it subjectExpectations, taking either 'acceptFirstUse' or { pinned: [...] }.

request is the short path for an application configured from a profile: it names a requirement and its selectors, and the client resolves the definition, prepares, sends, and verifies in one call.

result = client.request("adult-status", person_id="person-123")
print(result.values)
const result = await client.request({
requirement: 'adult-status',
selectors: { personId: 'person-123' },
});
console.log(result.values);

Python takes the requirement positionally and the selectors as keyword arguments, with response_format, subjects, and binding_receipt as named options. Node takes one AudienceScopedRequest object with requirement, responseFormat, selectors, subjects, and bindingReceipt. Use subjects when a request has several roles, or when a selector field needs a name the option map reserves.

The result is one of two variants, discriminated by the response format closed before the request was sent:

responseFormatNode typePython typePayload member
signed-jwsProgressiveVerifiedAssertionVerifiedAssertionassertion, the signed bytes; credential is null
sd-jwt-vcProgressiveVerifiedAudienceScopedCredentialVerifiedAudienceScopedCredentialcredential, the serialized credential; assertion is null

Both variants carry evidence, values, value, the trace identifier, and subject_continuity or subjectContinuity. Continuity has a status of first_use or matched (Node: firstUse or matched) and an opaque receipt. The receipt is the application’s to keep: nothing in the package persists one. Pass a retained receipt back as binding_receipt or bindingReceipt to assert that the next answer describes the same subject. Python serializes one with SubjectBindingReceipt.to_json and reads it back with SubjectBindingReceipt.from_json.

RawEvidenceResponse, VerifiedEvidence, and every progressive result carry the validated W3C trace identifier for the exchange, as trace_id in Python and traceId in Node. It is None or null when the response carried none. It is support correlation only: it is not an Evidence audit operation identity, and it is not a substitute for the deployment’s audit record.

Python raises one of eight subclasses of evidence.EvidenceClientError, so an application can catch the category it handles and let the rest propagate. Node throws evidence.EvidenceClientError with kind carrying the same eight values.

KindPython classMeaning
configurationConfigurationErrorThe construction inputs or the request specification were refused, or a prepared request had already spent its single send
nonceNonceErrorThe request nonce could not be generated
tokenTokenErrorThe configured credential source could not supply a bearer
transportTransportErrorThe exchange failed below HTTP
deniedDeniedErrorEvidence refused the request
not-availableNotAvailableErrorEvidence could not be produced: no match, an ambiguous match, a missing fact, or a source that answered with no record, all collapsed into one answer
protocolProtocolErrorThe response violated the contract
verificationVerificationErrorThe response did not satisfy the pinned verification

kind is always present. The rest are present only when the underlying failure carries them: status on denied, protocol, and a token failure whose token subcategory is protocol; code on denied, protocol, verification, and a token failure whose token subcategory is refused; the trace identifier on denied, not-available, and protocol; the retry delay on denied and protocol; the transport subcategory on transport and a token failure whose token subcategory is transport; and the token subcategory on every token failure. Python spells them status, code, trace_id, retry_after_seconds, transport_kind, and token_kind; Node spells them status, code, traceId, retryAfterSeconds, transportKind, and tokenKind.

The Base Registry Engine (BReg) client reads and writes Records, invokes Actions, and works Proposals in one registry. Every write goes through an opaque binding selected from the registry’s caller-filtered metadata. This section covers construction, reads, writes, and errors. The BReg client capabilities reference lists every operation, with Proposals, recovery after a restart, and the full error tables.

use registry_stack_client::breg::{BaseRegistryClient, BaseRegistryClientConfig};
use url::Url;
let config = BaseRegistryClientConfig::new(
Url::parse("https://breg.example.invalid/institution-a")?,
);
let client = BaseRegistryClient::new(config)?;
from registry_client import breg
client = breg.BaseRegistryClient(
base_url="https://breg.example.invalid/institution-a",
authorization={"static": token},
request_timeout_seconds=30,
connect_timeout_seconds=10,
max_response_bytes=8 * 1024 * 1024,
)
const { breg } = require('@registrystack/client');
const client = new breg.BaseRegistryClient({
baseUrl: 'https://breg.example.invalid/institution-a',
authorization: { static: token },
requestTimeoutMilliseconds: 30_000,
connectTimeoutMilliseconds: 10_000,
maxResponseBytes: 8 * 1024 * 1024,
});

The Rust facade does not re-export Url; add the url crate beside it. BaseRegistryClientConfig provides with_token_provider, with_request_timeout, with_connect_timeout, with_max_response_bytes, with_user_agent, with_trusted_root_certificates, and with_max_mutation_retries. Node and Python take the same settings as constructor options.

authorization is optional. Without it the client reaches only the health and readiness probes, which never send a bearer token; every other route refuses a request without one. An unsupported shape is refused at construction with kind configuration.

verifyWebhookDelivery in Node and verify_webhook_delivery in Python authenticate one exact BReg Version 1 webhook request with the shared Rust verifier. Pass the received method, request target, headers, body bytes, and key without normalizing them. The result carries the authenticated CloudEvents attributes, delivery metadata, and body. It does not decide whether the delivery is fresh, expected by this receiver, or already applied; the receiver checks those, in this order.

In both examples, event_store.apply_once() (eventStore.applyOnce() in Node) is an application-owned transaction that claims the event identity and commits the effect once, and apply_registry_event() applies the payload the receiver expects.

const { breg } = require('@registrystack/client');
const MAX_BODY_BYTES = 1_048_576;
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1_000;
const EXPECTED_SOURCE = 'urn:registrystack:registry:example:instance:primary';
const EXPECTED_TYPE = 'case-created-v1';
const EXPECTED_DATASCHEMA =
'urn:registrystack:registry:example:event:case-created-v1:schema:sha256:aaa';
function exactHeaders(rawHeaders) {
const headers = Object.create(null);
const names = new Set();
for (let index = 0; index < rawHeaders.length; index += 2) {
const name = rawHeaders[index];
const normalized = name.toLowerCase();
if (names.has(normalized)) throw new Error('duplicate webhook header');
names.add(normalized);
headers[name] = rawHeaders[index + 1];
}
return headers;
}
async function exactBody(request) {
const chunks = [];
let length = 0;
for await (const chunk of request) {
const bytes = Buffer.from(chunk);
length += bytes.length;
if (length > MAX_BODY_BYTES) throw new Error('webhook body is too large');
chunks.push(bytes);
}
return Buffer.concat(chunks, length);
}
async function receiveWebhook(request, response, key, eventStore) {
const body = await exactBody(request);
const delivery = breg.verifyWebhookDelivery({
method: request.method,
path: request.url,
headers: exactHeaders(request.rawHeaders),
body,
key,
});
const skew = Math.abs(Date.now() - Date.parse(delivery.deliveryTime));
if (!Number.isFinite(skew) || skew > MAX_CLOCK_SKEW_MS) {
throw new Error('webhook delivery is outside the accepted clock skew');
}
if (delivery.source !== EXPECTED_SOURCE
|| delivery.type !== EXPECTED_TYPE
|| delivery.dataschema !== EXPECTED_DATASCHEMA) {
throw new Error('unexpected webhook contract');
}
const eventIdentity = JSON.stringify([delivery.source, delivery.id]);
await eventStore.applyOnce(eventIdentity, async () => {
await applyRegistryEvent(JSON.parse(delivery.body.toString('utf8')));
});
response.writeHead(204).end();
}
import json
from datetime import datetime, timezone
from registry_client import breg
MAX_CLOCK_SKEW_SECONDS = 5 * 60
EXPECTED_SOURCE = "urn:registrystack:registry:example:instance:primary"
EXPECTED_TYPE = "case-created-v1"
EXPECTED_DATASCHEMA = (
"urn:registrystack:registry:example:event:case-created-v1:schema:sha256:aaa"
)
def receive_webhook(method, path, headers, body, key, event_store):
delivery = breg.verify_webhook_delivery(
method=method, path=path, headers=headers, body=body, key=key,
)
sent = datetime.fromisoformat(delivery["delivery_time"])
skew = abs((datetime.now(timezone.utc) - sent).total_seconds())
if skew > MAX_CLOCK_SKEW_SECONDS:
raise ValueError("webhook delivery is outside the accepted clock skew")
if (delivery["source"], delivery["type"], delivery["dataschema"]) != (
EXPECTED_SOURCE, EXPECTED_TYPE, EXPECTED_DATASCHEMA,
):
raise ValueError("unexpected webhook contract")
event_identity = json.dumps([delivery["source"], delivery["id"]])
event_store.apply_once(
event_identity, lambda: apply_registry_event(json.loads(delivery["body"])),
)

Python takes headers as a dict, which cannot hold a repeated header, so refuse a request that repeats one before building the dict, and bound the body size before reading it. Node refuses body and key buffers backed by shared memory and copies the rest before verifying.

Keep the key in server-side secret storage and never log the input or a failed delivery. A failure raises BaseRegistryClientError with kind webhook-verification; its code and message name the input to check without rendering its value. Generation and attempt are decimal strings, so a 64-bit generation never rounds in JavaScript. A delivery outside the skew bound can still carry a valid signature, so reject it before deduplication. The idempotency key identifies one delivery generation and survives automatic retries, but an operator replay creates a new generation and key. Deduplicate an effect that must happen once on source plus id, or on a business key. Webhooks covers provisioning the key and building a receiver.

OperationRust and PythonNode
Probeshealth(), ready()health(), ready()
Parsed registry metadataregistry_contract(access_profile)registryContract(accessProfile)
One Recordget_recordgetRecord(route, recordIdentifier, options)
First page of a listlist_recordslistRecords(route, options)
Next pagecontinue_list(continuation)continueList(continuation)
Lookup by selectorlookup_recordlookupRecord(route, selector, values, options)

route is the Record type’s API route. Python takes read options as keyword arguments: select, access_profile, and format (json or json-ld), plus top, filter, orderby, count, and bbox for a list. Node takes the same options in one object, in camel case.

An outcome is a dict in Python and an object in Node. It carries kind (complete), value, the trace identifier, and etag and location when the response had them. A record read carries an ETag; lists and lookups do not. A list outcome adds continuation: pass it unchanged to continue_list or continueList. It cannot be combined with first-page options. registry_contract returns BRegMetadata itself, the source of every binding below.

Select a binding from BRegMetadata before a write: selectCreate or selectPatch (snake case in Python; select_direct_write in Rust), then pass it back to the client. A binding exposes nothing and grants nothing; it ties the call to the client’s origin, profile, route, and request contract. If the metadata does not offer the operation, selection fails with kind metadata-selection.

Python: create_record(binding, data, idempotency_key, *, format="json") and patch_record(binding, record_identifier, etag, operations, idempotency_key, *, format="json"). Node: createRecord(binding, data, idempotencyKey, format) and patchRecord(binding, recordIdentifier, etag, operations, idempotencyKey, format). data maps API field names to JSON values. Each patch operation is {"op": "add" | "replace" | "test", "field": ..., "value": ...} or {"op": "remove", "field": ...}. A patch needs the ETag from a fresh read through the same route and profile. The client checks field names, value bounds, nesting, and body size before sending; an input it refuses raises kind invalid-request with no exchange.

An idempotency key is 1 to 256 visible ASCII characters, excluding comma and semicolon. The client never creates one, and resends a write only under the same-key retry: same binding, body, precondition, representation, and key. BReg keeps the response for a committed write for retention.receiptRetentionDays. A resend within that horizon replays it; a resend after it answers 410 idempotency.expired. That 410 is a known outcome: the write committed.

selectAction picks an Action, whose mode is fixed: an immediate Invoke or a durable Proposal Start. Invoke with invokeAction; start durable work with startProposal, then list, read, and move the Proposal through its transitions with prepareProposal and executeProposal. Each Proposal link carries its own ifMatch condition, never a Record ETag. Python takes dicts throughout. The client capabilities reference covers Proposals, submissions, review reads, attachments, batch, tombstone, and recovery after a restart.

Three Node methods take JSON text rather than plain objects: startProposal(binding, inputsJson, key), listProposals(collection, queryJson), and prepareProposal(proposal, operation, bodyJson, key). They return opaque Proposal objects; read documentJson for the validated representation. Many other methods have a *Json twin that returns the validated response as exact text in valueJson, for applications that parse JSON numbers losslessly.

Python raises breg.BaseRegistryClientError, an Exception, with kind, code, refusal_code, reason_code, field_path, constraint, status, trace_id, transport_kind, token_kind, and the boolean outcome_unknown; an absent member is None. Node rejects with breg.BaseRegistryClientError, an Error with kind, the boolean outcomeUnknown, and the optional code, refusalCode, reasonCode, fieldPath, constraint, status, traceId, transportKind, and tokenKind.

KindMeaning
configurationConstruction inputs were refused
invalid-requestAn input was refused before any exchange
tokenThe token provider could not supply a bearer
transportThe exchange failed below HTTP
not-foundThe server answered resource.not_found
problemThe server answered with any other Problem
protocolThe response broke the contract
metadata-selectionNo binding matched the metadata
webhook-verificationA webhook delivery failed authentication
clientAny other client-side failure

refusalCode carries the declared reason for action.refused or statistical_dataset.release_refused, and reasonCode the reason for statistical_dataset.version_withdrawn. A row-local constraint conflict sets constraint, with recordType, constraintId, constraintKind, and left and right for compare or field for int-range and vocabulary. The server omits it when any field involved is unreadable to the caller, and default error rendering leaves it out.

A Problem code this client does not know arrives as kind problem with only status and the trace identifier. Decide on the status: an unknown 4xx is a refusal, and an unknown 5xx may have committed, so follow the same-key retry. idempotency.expired is a known code. The error tables list which attributes each kind sets and every code value.

The Casework client covers pinned review policies, producer requests and results, human review tasks, and Directory operations. A ReviewResult records the human outcome. You independently authorize any later business mutation in the producer’s system.

use registry_stack_client::casework::{CaseworkClient, CaseworkClientConfig};
let client = CaseworkClient::new(CaseworkClientConfig::new(
"https://casework.example.invalid/".parse()?,
))?;
from registry_client import casework
client = casework.CaseworkClient("https://casework.example.invalid/")
const { casework } = require('@registrystack/client');
const client = new casework.CaseworkClient({
baseUrl: 'https://casework.example.invalid/',
});

CaseworkClientConfig provides request and connection timeouts, a response-size bound, trusted root certificates, a user agent, and a mutation retry ceiling. Python takes these as constructor keyword arguments; Node uses config members. Responses are bounded to 4 MiB by default and 16 MiB at most. Configuration retains no bearer token or selected profile.

For producer, human-review, and Directory calls, pass the request’s bearer token and Casework profile. Rust uses CaseworkAuth::new(&token, profile); Node and Python take these as the first two arguments. Current-source context operations also take a source profile as an optional final argument, or through Rust’s with_source_profile. Submitted-context reviews need no provider or source profile. The selected source profile travels in Registry-Source-Profile, never in a decision body.

Producer authority is separate from human review authority. Agent assertion and grant-status calls take only a bearer token and grant ID. The local digest helper takes no credential and performs no I/O.

Select the policy by ID in ReviewCreateRequest.policy; Casework freezes its concrete version and digest at acceptance. To require an exact pin, use review_policy (reviewPolicy in Node) to discover a compiled closure and supply its complete identity, or use your reviewed project’s binding. See the Casework API policy contract for selector validation. Submitted context uses mode: submitted and a bounded snapshot; current-source context uses mode: current-source and a reference binding. The subject supplies all five immutable binding fields.

These host-side functions take your complete ReviewCreateRequest, declared trusted-producer ID, and retained idempotency key. They use the shared Rust canonicalizer, including its portable JSON validation and Unicode key ordering.

import { casework } from '@registrystack/client';
const client = new casework.CaseworkClient({ baseUrl: 'https://casework.example.invalid/' });
async function submitReview(
token: string,
producerId: string,
request: casework.ReviewCreateRequest,
key: string,
) {
const digest = casework.submissionDigest(producerId, request.subject.source, request);
return client.createOrRecoverReviewRequest(token, 'requester', key, request, digest);
}
def submit_review(token, producer_id, request, key):
digest = casework.submission_digest(producer_id, request["subject"]["source"], request)
return client.create_or_recover_review_request(token, "requester", key, request, digest)

Rust exposes the same local helper as submission_digest(producer_id, source_namespace, &request). Persist the accepted concrete request binding. Recover a lost response with the original selector, subject, request body, key, and digest, even after a policy update. Do not rewrite an ID-only selector into the accepted exact binding during recovery: that changes the submission identity. Read review_result with the accepted binding or resume the authenticated review_results feed. Producer create and cancel calls are never automatically resent.

Rust and Python use snake case; Node uses the corresponding camel case.

CallerOperationsBoundary
Producerreview_policies, review_policy, request creation, cancellation, result reads and feedExact admitted producer and subject binding
Human reviewerreview_tasks, next_review_task, review_task_holdings, task context, claim, release, assignment, delegation, private drafts, decisions, history and notesCurrent profile, membership, exclusions and ownership; current source disclosure for lists, open work, context and new notes
Deciding callerown_review_decisions, own single-task receiptRetained authorship, not prior holding
Supervisorsupervisory_review_tasks, review_accountabilityCurrently served queues; accountability is a separate audited read
AdministratorDirectory, absence, holiday, caseload and clock preview/apply operationsAdministration grants no review or external write authority

Retained decided-task and terminal-history reads use current Casework authority and result retention. They do not require current source disclosure and never release source context. Own-decisions and supervision lists still require current source disclosure.

ReviewStaffingQuery supports queue, ownership, visible reference, and either all five exact subject selectors or none. Sort by due, subject, or reference. next_review_task is due-ordered and returns one task or no task; holdings return a bounded page. Caseload and clock apply calls send the reviewed preview ID and caller’s key. The runtime revision-fences the complete preview set; clock recomputation uses pinned policy meaning.

Task mutations take the task UUID, displayed numeric revision, and caller-chosen idempotency key. The client constructs the route and quoted revision header. Follow the same-key retry contract for ambiguous keyed mutations; never invent a replacement key merely because the response was lost.

Read review_task to recover the caller’s retained decisionReceipt, or use own_review_decisions after a fresh sign-in. Receipts disclose the deciding caller’s outcome and pinned label without private reason or structured result data. Supervisory discovery returns bounded task/accountability references with a holder-free state; it grants no decision or context authority. It intentionally omits holder identities and due dates and does not provide a team holdings board. The separate audited accountability read has its own retention.

Caseload preview selects the source officer’s complete claimed set, optionally within one queue, up to 100 tasks. It has no subset, limit or continuation parameter. A larger set refuses with review.query-too-broad. Apply is atomic over the exact previewed set; individual task assignments remain available for a selected subset, with independent revisions and outcomes.

Successful calls return CaseworkComplete<T> in Rust, a Python dict with kind: complete, value, and trace_id, or a Node object with kind, value, and traceId. Wire DTO fields remain camel case in both language bindings. Result reads instead distinguish available, pending, concealed_or_unknown, and expired; only available carries a result.

Pass nextCursor unchanged through the same operation with the same caller, profiles, and query. Staffing cursors are opaque visibility snapshots valid for at most 15 minutes. Holding, membership, or current-source visibility changes can invalidate them. After 410 review.result-expired, restart without the cursor. Task list, next-task and holdings reads scan the complete candidate set before sorting or paging, including unfiltered reads. Exhausted candidate or source-read budgets return 422 review.query-too-broad, with no partial page; narrow the queue or exact subject selectors before retrying. A smaller page size or a visible reference filter does not reduce that scan. See the inbox keys of the runtime configuration. An unavailable required provider is a failure, not an empty reviewer inbox.

CaseworkClientError is distinct from every other product’s error. Rust uses Configuration, InvalidRequest, Transport, Problem, and Protocol. Python raises casework.CaseworkClientError; Node rejects with the same named error class. The language bindings expose kind, status, problem code, validated trace context, bounded validation detail, transport category, protocol failure, and outcome_unknown (Node: outcomeUnknown) when applicable. Errors do not repeat rejected values or credentials.

Unknown problem codes retain the status and trace context without gaining known recovery meaning. review.policy-mismatch is a 409 refusal of the expected policy binding. Validation details name a bounded field path and closed reason, such as policy-not-allowed, schema-mismatch, outcome-not-declared, result-required, or constraint-violated. A 410 idempotency.expired requires reconciling the original task, request, or Directory operation before choosing a new key.

See the Casework API contract for exact Problems and headers.

The Messaging client covers six routes: liveness, readiness, the submission of one message, the read of one message’s status, the cancellation of one message before dispatch, and the preview of one template version. It carries no operator route and no provider callback route. The client never reaches the Messaging runtime or its operator tooling; it speaks the public HTTP contract any caller uses.

use registry_messaging_client::{MessagingClient, MessagingClientConfig};
use url::Url;
let client = MessagingClient::new(MessagingClientConfig::new(
Url::parse("https://messaging.example.invalid/")?,
))?;
from registry_client import messaging
client = messaging.MessagingClient("https://messaging.example.invalid/")
const { messaging } = require('@registrystack/client');
const client = new messaging.MessagingClient({
baseUrl: 'https://messaging.example.invalid/',
});

MessagingClientConfig provides with_request_timeout, with_connect_timeout, with_max_response_bytes, with_user_agent, and with_trusted_root_certificates. The Python constructor takes the same settings as keyword arguments and the Node constructor as config members. with_max_mutation_retries, the Python max_mutation_retries keyword, and the Node maxMutationRetries member set the same-key retry count for submit. A response is bounded to 4 MiB by default and to 16 MiB at most. The configuration holds no credential, and its debug output redacts the base URL, the user agent, and any trusted root certificates.

Method names are given in the Rust and Python form. Node uses the camel case form of the same name.

MethodTakesReturns
healthNothingAn empty value while the process serves
readyNothingAn empty value when the runtime can serve, and a service.unavailable problem otherwise
submitThe bearer token, an idempotency key, and the submissionThe MessageReceipt: the message id, its status, and links
messageThe bearer token and a message identifierThe MessageView: status, dispatch, report, the attempt summaries, and links
cancelThe bearer token and a message identifierThe MessageView as the cancellation left it
previewThe bearer token, a template identifier, a version, and a TemplatePreviewRequest of locale and dataThe TemplatePreview: the template reference, locale, channel, packageDigest, the rendered parts, and sms segment counts for an SMS template

Every call that reaches a caller route takes the request’s bearer token as its first argument and retains none. In Rust it is a BearerToken, built with BearerToken::new(token). The module is for a trusted server host, the way the Casework module is.

A submission requires an idempotency key the caller chose: 1 to 128 visible ASCII characters, refused before any exchange otherwise. A key is scoped to the caller’s verified issuer and subject. The client never generates or replaces a key, and resends a submission only under the same-key retry. After a lost response it still reports, send the same key with the same submission within retention.receiptRetentionDays, and the runtime answers the stored receipt instead of accepting a second message. A 403 or 422 answering an exact retry does not show that the first attempt failed, so never resubmit under a new key: have the operator restore the access, template version, or locale the refusal names, then retry under the same key within retention.receiptRetentionDays, as the replay rule explains. A message identifier must be the lowercase hyphenated UUID the runtime answered; any other value is refused before any exchange. The view carries the recipient’s channel with the value redacted, and no rendered content.

cancel withdraws a message whose dispatch has not started. A cancellation that lost the race answers message.dispatch-started when dispatch began first, and message.terminal when the message was already final; the client never retries either. preview renders one template version for a locale and data and sends nothing. The template identifier must be a lowercase kebab identifier of at most 64 bytes and the version a package version label of at most 32 bytes, and both are refused before any exchange otherwise. A version the active package does not ship answers template.not-found; data its schema refuses, a locale it does not carry, and a render the runtime stopped answer template.data-invalid, template.locale-unavailable, and template.render-refused.

receipt = client.submit(
token,
"reminder-case-42-1",
{
"senderProfile": "reminders-sms",
"to": {"phone": "+15555550100"},
"template": {"id": "appointment-reminder-sms", "version": "1"},
"locale": "en",
"data": {"name": "Ada", "day": "2026-10-01", "office": "North"},
},
)
view = client.message(token, receipt["value"]["id"])
cancelled = client.cancel(token, receipt["value"]["id"])
preview = client.preview(
token,
"appointment-reminder-sms",
"1",
{"locale": "en", "data": {"name": "Ada", "day": "2026-10-01", "office": "North"}},
)

A successful call returns MessagingComplete<T> in Rust, a dict carrying kind (complete), value, and trace_id in Python, and an object carrying kind, value, and traceId in Node. The value is the camel case wire DTO the service returned; health and ready carry none.

MessagingClientError is distinct from every other namespace’s error type. Rust answers with Configuration, InvalidRequest, Transport, Problem, or Protocol. A Problem carries the status, the typed ProblemCode from the Messaging problem catalogue, and the validated trace identifier. A Protocol carries a MessagingProtocolFailure of HeaderBounds, TraceContext, Body, MediaType, Problem, or Status.

The catalogue is closed. A problem becomes a typed Problem only when its code is in the catalogue, its status is the one the code pins, its type URI is the pinned one, and its trace identifier matches the response’s trace header. Anything else, including a code the client does not know, is a Protocol failure with Problem. The title and detail are not compared: the caller reads the pinned copies through code.title() and code.detail(), so a deployment that rewords them still answers a refusal the caller can match.

A submission over its access profile’s request rate or daily limit is refused with rate-limit.exceeded or quota.exceeded and status 429. That Problem also carries retry_after_seconds, the whole seconds the response’s Retry-After asked the caller to wait. The client accepts only a single delta-seconds value from 1 through MAXIMUM_RETRY_AFTER_SECONDS (86,400, one day); an absent, repeated, or out-of-range header leaves the wait unset and the refusal still typed. The wait is unset on every other status. The client never waits out a 429 or resends the refused submission.

Python raises messaging.MessagingClientError, an Exception whose attributes are kind, code, title, detail, status, trace_id, retry_after_seconds, transport_kind, protocol_failure, and the boolean outcome_unknown; an absent member is None. Node rejects with messaging.MessagingClientError, an Error with kind, the boolean outcomeUnknown, and the optional code, title, detail, status, traceId, retryAfterSeconds, transportKind, and protocolFailure; an absent member is undefined. The MessagingProblemCode type in both bindings names every code of the catalogue, including rate-limit.exceeded and quota.exceeded.

KindMeaningMembers set
configurationThe construction inputs were refusednone
invalid-requestAn input was refused before any exchangenone
transportThe exchange failed below HTTPtransport_kind
problemMessaging answered with an accepted problemstatus, code, title, detail, and trace_id, plus retry_after_seconds on a 429 that named a wait
protocolThe response violated the contractstatus, protocol_failure, and trace_id when one was validated

The Scheduling client reads the published catalogue and availability, explains availability at one start, places and releases holds, and books, reschedules, cancels, and reads appointments. A hold, a booking, a reschedule, and a cancellation take a caller-chosen idempotency key, which a binding never generates. The Registry Scheduling API holds the authority profiles, task grants, and problem codes each operation assumes.

use registry_scheduling_client::{SchedulingClient, SchedulingClientConfig};
use url::Url;
let client = SchedulingClient::new(SchedulingClientConfig::new(
Url::parse("https://scheduling.example.invalid/")?,
))?;
from registry_client import scheduling
client = scheduling.SchedulingClient("https://scheduling.example.invalid/")
const { scheduling } = require('@registrystack/client');
const client = new scheduling.SchedulingClient({
baseUrl: 'https://scheduling.example.invalid/',
});

SchedulingClientConfig provides with_request_timeout, with_connect_timeout, with_max_response_bytes, with_user_agent, with_trusted_root_certificates, and with_max_mutation_retries. The Python constructor takes the same settings as keyword arguments and the Node constructor as config members.

The canonical Rust clients own product-neutral outbound policy, bearer acquisition, prefix-safe route construction, bounded reads, OAuth response decoding, trace validation, and Problem validation. Product-specific request models and fixed route semantics stay in each product client. Deployment-defined data stays dynamic at the SDK boundary.