What it does
Circuit is a local operation ledger: reserve an identity before a side effect, record its attempts and keep unresolved work from being replaced on the same execution leg. Use it when a retry must recover the same operation rather than accidentally starting another.
A leg is your application's resource key, such as one invoice or position. The guard combines scopeKey and legKey; it is useful only when competing operations use the same stable keys. Circuit records authorization/evidence supplied by its caller. It does not grant permission, send a transaction or independently verify chain truth.
Use independently
Select uk.capp.sidecar.solana-circuit in local-store mode. It needs no wallet, RPC or Edge. Use window.capp.solana.circuit or the portable client.
Choose operation, scope and leg IDs before invoking the provider; persist those IDs with the business request. In particular, do not create a new operation ID in a retry handler. Omitting a leg removes this particular cross-operation pending guard.
Provider dependencies
| Mode | Required service | Responsibility |
|---|---|---|
local-store | None | App/origin-scoped records; the application supplies observed outcomes. |
external-adapter | Configured operation-ledger service | The service must implement identity, state and persistence semantics. A URL is not distributed durability. |
Edge uses Circuit as part of its devnet flow. Direct users may pair it with Dispatch and Witness, or with a completely different application workflow. Circuit does not discover the relevant attempt by scanning another component.
Reserve before doing work
This module uses the portable client. It records a reservation only: there is no preflight, wallet prompt or submission. Invoke it from the application action that begins the business operation, and retain the same arguments for recovery.
import {createSolanaClient} from './solana-client.mjs';
const client = createSolanaClient(() => window.capp?.solana);
export async function reserveCircuitLeg({operationId, scopeKey, legKey, intentDigest}) {
if (!operationId || !scopeKey || !legKey) {
throw new Error('Supply stable operation, scope and leg IDs');
}
const status = await client.call('circuit', 'status');
if (!status.ready || status.providerMode !== 'local-store') {
throw new Error('This example requires a ready local Circuit store');
}
const input = {operationId, scopeKey, legKey};
if (intentDigest !== undefined) input.intentDigest = intentDigest;
const operation = await client.call('circuit', 'reserve', input);
const trace = await client.call('circuit', 'trace', {operationId});
return {operationId: operation.operationId, idempotent: operation.idempotent,
state: trace.state, attempts: trace.attempts, stale: trace.stale};
}Call it with {operationId: 'docs-order-1', scopeKey: 'docs-shop', legKey: 'invoice-1'}. The first result is reserved; the same call is idempotent. A different operation with the same scope and leg is refused until the original reaches a justified terminal state. An idempotent result might already be submitting or finalised: do not restart its workflow from the beginning.
State and attempt lifecycle
| Current state | Supported next states |
|---|---|
reserved | prepared, preflighted, cancelled, failed |
prepared | preflighted, cancelled, failed |
preflighted | authority-pending, authorised, cancelled, failed |
authority-pending | authorised, cancelled, failed |
authorised | submitting, cancelled, failed |
submitting | submitted, unknown, finalised, failed, expired |
submitted | unknown, finalised, failed, expired |
unknown | submitted, finalised, failed, expired |
finalised, failed, cancelled, expired | Terminal; no transition to another state |
Use beginAttempt() after actual authorization. It retains the message/transaction binding, creates or returns the exact attempt, and moves the operation to submitting. Do not substitute a bare state transition for recording the attempt.
After an attempt exists, identify it when transitioning submitted or uncertain work. Terminal transitions after submission require a 64-character SHA-256 evidenceDigest; when supplied, attemptState must equal state. The ledger checks the binding and digest shape, not whether the evidence is true. Only an authoritative observation should release a real pending leg. confirmed is an observed state, not a Circuit lifecycle state; use submitted with observedState: confirmed until the required terminal outcome is established.
Scope and limitations
Local persistence is scoped to this app and origin. It is not a distributed database or a cross-device lock. A timeout does not release an unresolved leg. pendingTtlSeconds affects the trace().stale flag only; pendingLocksExpire remains false.
The runtime serializes its requests and uses Web Locks when available. Do not infer a global lock across devices, other origins or contexts without that facility. Source status deliberately distinguishes localPersistence: true from durableOperations: false.
The local implementation retains at most 64 attempts and 512 timeline entries per operation, in addition to the configured operation/store limits. Capacity, corrupted state or a failed storage write is a stop-and-recover condition. Preserve records and restore storage rather than clearing a lock to make the UI proceed.
Application API
The owning interface is window.capp.solana.circuit. The portable client calls the same methods. This table describes main arguments, not a stricter typed guarantee than the current provider implements. Exact transaction/provider bindings must be preserved; unsupported state or arguments are rejected.
import {createSolanaClient} from './solana-client.mjs';
const client = createSolanaClient(() => window.capp?.solana);
const status = await client.call('circuit', 'status');status
client.call('circuit', 'status', input)
Main input: No arguments required.
Read local-store health, capacity and pending-leg behaviour.
reserve
client.call('circuit', 'reserve', input)
Main input: operationId; optional scopeKey, legKey, intentDigest and position identity.
Reserve an idempotent operation. A conflicting identity or an unresolved operation on the same scoped leg is rejected.
read
client.call('circuit', 'read', input)
Main input: operationId.
Read the retained operation. A missing operation is an error, not an empty successful result.
beginAttempt
client.call('circuit', 'beginAttempt', input)
Main input: operationId, messageDigest; optional attemptId, transactionDigest, signature.
Reserve a submission attempt only after authorization. Conflicting bytes and existing pending attempts are rejected.
transition
client.call('circuit', 'transition', input)
Main input: operationId, state; attemptId after submission; evidenceDigest for a terminal post-submission result.
Advance a supported state transition. This records supplied evidence; it does not independently establish chain truth.
trace
client.call('circuit', 'trace', input)
Main input: operationId.
Read attempts, timeline, digest and staleness. Staleness never releases an unresolved leg automatically.
Troubleshooting
| Symptom or error | Next step |
|---|---|
LEG_PENDING | Read the reported owning operation and reconcile its attempt. Elapsed time is not an outcome. |
ID_CONFLICT | Keep scope, leg and supplied digests unchanged for the same operation/attempt. |
STATE | Check the transition table; authorization and terminal states are not interchangeable. |
ATTEMPT or ATTEMPT_PENDING | Use the recorded attempt ID. Do not replace an unresolved attempt. |
EVIDENCE | Obtain and retain actual outcome evidence before closing submitted work. |
STORAGE_* or CAPACITY | Stop new effects, preserve the existing store and recover its ability to write. |
Verify your integration
Test same-ID replay, same-leg contention, an altered intent digest, reload with a pending attempt, terminal evidence requirements and storage-write failure. Confirm that a stale unknown operation still owns its leg. For an external ledger, separately test its concurrent-client and restart behaviour.
Integration recipes
Composition · Devnet payment · Recovery · Typed client · Schemas
Related data contracts
These schemas describe payload shape, not evidence of approval or successful execution. Match the version in your payload; older versions remain listed for compatibility.
Configuration reference
These are the selected package's configuration fields. String credential values and installation-specific defaults are never published here. A field's presence does not bypass its runtime policy.
| Field | Type | Meaning and constraints |
|---|---|---|
providerMode | select | Select the implementation this build will use. Configuring an endpoint does not verify its connection or behavior.. Choices: local-store, external-adapter |
maxOperations | number | Maximum local records. At the limit, new records stop; existing history is preserved. Increase the limit or archive and reconcile the workspace.. Bounds: 16 to 4096 |
pendingTtlSeconds | number | Marks an old record as stale in its trace. This never releases a pending lock.. Bounds: 10 to 86400 |
externalAdapterUrl | text | Absolute HTTPS endpoint, or loopback HTTP for development. Keep private credentials on the backend; packaged configuration is visible to users. |
Agent interfaces
These names are declared by the package for host/Agent integration, not JavaScript methods to invoke directly. The host must register a tool, grant its scope and enforce its approval policy. A manifest declaration alone does not prove availability in every Agent catalogue. Prefer the application API above when integrating your own app.
| Tool | Declared risk | Approval requirement |
|---|---|---|
solana.circuit.status | low | mode-policy |