This document compares the POC's API with the original Formance ledger API and documents missing features.
URL prefix. All business endpoints in this POC are served under the
/v3prefix (e.g.POST /v3/{ledgerName}/transactions). Ops endpoints (/health,/livez,/readyz,/clusterz,/_info,/debug/pprof) are intentionally unversioned. The original ledger's/v2is not preserved by this POC — there is no compatibility shim.
Every /v3 operation rejects unknown query parameter names with HTTP 400
INVALID_REQUEST before execution. The removed account prefix and transaction
reference selectors use filter; HTTP does not support checkpointId for
pinned reads, which remain available through gRPC. The unversioned ops routes
retain their existing query handling. See query parameter validation.
For the per-operation Ledger v2 → v3 path mapping, see
v2-to-v3-endpoint-map.json. Update it in the same
change when a route it names is renamed or removed.
Cluster membership changes are exposed through the v3 ClusterService; the v2
business endpoint map has no corresponding operation. Internal retries of one
add, promote or remove call retain the original committed outcome through slow
WAL persistence, rather than reporting a second admission error after the
membership already changed. Success still requires local durable FSM apply;
distinct calls keep their existing admission and cancellation semantics. This
correction changes no route, message, field, status schema or protocol revision.
The additive ClusterService.BeginDrain, EndDrain, and local GetDrainStatus RPCs support
an operator-controlled shutdown barrier. They have no v2 business endpoint
equivalent. BeginDrain and EndDrain require ledger:WriteCluster and become effective
only after every current member confirms the new capability; an older server
returns Unimplemented for GetDrainStatus. The new ClusterState drain fields
are additive and absent on older binaries. The service protocol revision stays
at 40. A committed marker adds internal Raft and cluster-transient state but
does not change audit or business data formats. See
controlled shutdown.
HTTP typed metadata preserves exact integer values, including signed 64-bit
bounds and values above 2^53, for metadata writes, transaction creation and
reversal (unitary and bulk). Integral decimal/exponent spellings are accepted;
fractions and out-of-range values are rejected before submission. The OpenAPI
integer schema spans -9223372036854775808 through 18446744073709551615,
matching signed negative and unsigned nonnegative metadata values. See
Metadata number decoding.
V3 adds opt-in signing headers with application/json for the
existing transaction create/revert/metadata POST, transaction metadata DELETE,
and nonempty atomic=true bulk operations. The wrapper carries the ordinary
REST body in request (null for DELETE) and the standard-base64 payload of the
complete protobuf batch derived from the URL, body and Idempotency-Key.
Formance-Signing-Key-Id and Formance-Signature headers carry its key ID and
standard-base64 Ed25519 signature; the adapter assembles the existing
SignedApplyBatch. Each header is required exactly once with a nonempty value;
the signature must decode to 64 bytes. Either header activates signed handling,
even if empty; neither header preserves the ordinary unsigned REST body. JSON
MIME parameters are accepted. The complete decoded protobuf values must match before the original signed bytes
reach admission. Atomic bulk preserves its header key, ignored per-entry ik,
request order and skippable reasons. Signed non-atomic bulk returns 400.
V2's transaction and bulk methods/routes, ordinary JSON field names, envelopes,
defaults and header identity remain the comparison baseline. Their ordinary v3
JSON counterparts keep those established contracts; the opt-in signing headers
and wrapper are an intentional additive divergence because v2 provides no equivalent opaque
ApplyBatch signature proof. No route is renamed, so the endpoint map is
unchanged. Response envelopes and bigint negotiation are unchanged. Request
signing does not provide HTTP response authenticity.
Malformed signed wrappers, missing/empty/repeated signing headers, invalid
base64 and invalid signature length return 400; HTTP-to-signed-batch mismatch
and invalid, unknown or revoked signing keys return 403 PERMISSION_DENIED. Mandatory missing
signatures on ordinary unsigned requests return 401 UNAUTHENTICATED.
Unsupported routes return 415 for either signing header; either signing header
with a non-JSON or missing content type also returns 415. Bulk 401/403
cannot be suppressed by continueOnFailure, and all processing outcomes remain available. JWT authentication/scopes remain separate.
Forwarded signing failures use dedicated validated ledger.signing ErrorInfo
details; bare/foreign gRPC auth failures retain the existing 500 contract.
The 4 MiB body budget includes the whole wrapper and base64, reducing usable signed capacity. Upgrade receiving HTTP nodes before clients opt in. No protobuf, protocol revision (40), proposal or persisted format changes, or migration are needed. Existing retained-key replay, unkeyed re-execution and expired-key rules are unchanged. See HTTP request signing.
Transaction creation (including bulk) rejects malformed accountMetadata
addresses at admission using the existing VALIDATION error contract. When
several addresses are invalid, the lexicographically smallest invalid address
selects the typed failure. These structural rejections no longer create an audit
entry or freeze an idempotency outcome. Valid requests, routes, fields, response
envelopes and protocol revision 40 are unchanged. Committed entries retain
processor validation. In v2.4.16, TransactionRequest.ToCore in
internal/api/bulking/elements.go forwards account metadata unchanged. The
stricter v3 address constraint already existed in its processor; this change
makes the rejection earlier and deterministic to protect replicated audit
history. See the rollout restriction.
A v3 write whose original metadata no longer fits the committed policy can
recover its retained keyed success or definitive failure. After normal
conversion, readiness and preload checks, the ordinary proposal reaches the
FSM. Same key/content replays; live different content creates an audited
conflict; missing/expired keys execute under the policy at apply and a definitive
METADATA_LIMIT_EXCEEDED is audited with no committed business effects. A keyed
refusal remains frozen even if policy is later loosened. This covers unitary
and bulk writes converging on admission; transport caps and earlier structural
or preparation failures still apply.
Routes, fields, envelopes, failure types and protocol revision 40 are unchanged.
This is a v3 replicated-policy recovery correction; it does not introduce a v2
metadata ceiling or change the existing v2-to-v3 endpoint mapping. In the v2
reference at revision d47ba1746cec2173d84eab8ec575be196eae0837, internal/controller/ledger/log_process.go
checks idempotency in forgeLog (line 148) before runLog, and
fetchLogWithIK (line 221) verifies the retained content hash. The v3 recovery
preserves that lookup-before-execution behavior while keeping its replicated
metadata ceilings. Existing conflict presentation intentionally differs: v3
returns HTTP 409 IDEMPOTENCY_KEY_CONFLICT; v2 returns HTTP 400 VALIDATION.
This correction changes neither contract. See
the replay contract
and client upgrade behavior.
The v3 gRPC CheckStore and ValidateRestore streams verify canonical and global
ledger policy rows independently against audited FSM replay, including retained
tombstones. CheckStore's additive LEDGER_INFO_MISMATCH enum value (32) reports identity,
deletion-state, mode and mirror-source mismatches; existing field-specific error
types remain. Protocol revision stays 40. Clients must retain failure semantics
for unknown numeric error types; refreshed bindings expose the new symbolic name.
ValidateRestore reports the same corruptions through its existing message-only
ValidateRestoreError events, without an error-type enum.
See protocol compatibility.
There is no HTTP endpoint or envelope change, and v2 has no equivalent integrity
stream requiring endpoint parity.
Cluster commands interrupted by local Raft shutdown now return gRPC
Unavailable rather than a sanitized Unknown, allowing retry against a live
node. This changes no HTTP route, request/response schema, or protocol revision.
The v2 API has no equivalent Raft cluster-command lifecycle.
Get/list use camelCase LedgerInfo properties and typed metadata; mode, enforcement
and account persistence use explicit short enum defaults. Mirror progress includes
zero values and unsigned numeric counters (integer/uint64 in OpenAPI), covering
the full 0..18446744073709551615 range; clients must use lossless integer
parsing above JavaScript's safe-integer range. v2 stats/log IDs
use numeric JSON with integer/int64 or integer/bigint; v2 has no equivalent
mirror progress.
Connection redaction selects formats by declared protobuf field identity.
Supported PostgreSQL, NATS, ClickHouse and HTTP(S) values retain safe connection
information; unsupported cross-format values and unregistered annotated fields
are fully masked. New PostgreSQL mirror configurations require a generic
hierarchical URL with an explicit scheme; the worker validates PostgreSQL
schemes/options and IAM TLS before installing credentials. Admission only checks
shape and required IAM region; invalid driver settings can be committed and
then fail source construction. There is no equivalent mirror-creation API in v2;
new NATS sinks require an explicit scheme per server; PostgreSQL and NATS retain
empty/default hosts and port-only authorities. Explicit ports on named hosts,
IPv6 hosts and port-only authorities must be in the 0–65535 range, including
each host in a comma-separated ClickHouse authority. ClickHouse
sinks require clickhouse://, http:// or https://. Invalid forms fail before Raft with safe
VALIDATION errors (HTTP 400 / gRPC InvalidArgument). Existing committed values
remain usable and legacy keyword DSNs are fully masked on reads. Endpoints and
envelopes are unchanged. The v2 API has no equivalent typed mirror/sink
configuration endpoints, so this v3-specific input policy has no v2 request to
preserve; see sensitive projection.
Nested read mirror configuration masks passwordless HTTP(S) URL userinfo as
well as passwords and uses a distinct schema
from unchanged flat creation input. Read rewrite actions model protojson
valueExpr and optional empty replacements; creation schemas retain
value_expr and their existing required fields. v2 has no mirror rewrite-rule
configuration counterpart. gRPC revision 29 covers detached current and
checkpoint projections. See the contract.
Creating or updating a prepared query rejects a filter leaf whose shape can
never compile (a missing value or field reference, or an unsupported builtin
field) with FILTER_COMPILATION_ERROR, instead of storing a query whose every
execution fails. Service protocol revision 21 accompanies this rejection.
Privileged JWTs use the boolean superuser claim. Static Ed25519 keys must
explicitly allow superuser in their key configuration. The CLI exposes
--superuser, operator Credentials expose spec.superuser, and audit callers
report superuser. Service protocol revision 19 accompanies this rename;
all granular-scope authorization behavior is unchanged.
Newly admitted OIDC writes record the verified issuer as the caller source,
including tokens with a JWT kid; rotating that signing key preserves the
issuer/subject identity. Static Ed25519 writes retain their signing key ID,
including tokens that carry an issuer claim. This corrects attribution capture
on HTTP and gRPC without changing token acceptance, authorization, endpoint
shapes, or service protocol revision 40. Previously committed snapshots remain
unchanged. See caller identity.
Revision 30 replaces failure messages stored in audit, idempotency, and
skipped-order records with reason, subcode, and structured facts. Revision 32
stores those facts in a FailureDetails protobuf oneof selected by the
concrete code. Revision 38 derives the code from that selected arm instead of
storing it again. Revision 39 derives the coarse reason from the same arm,
including in audit, idempotency, and skipped-order records. Bulk skip responses
still expose reason, code, and facts;
audit reads render messages from the stored fields. HTTP clients do not send
the gRPC revision.
The v3 gRPC service requires one ledger-protocol-version metadata value per
business RPC, equal to pkg/grpcprotocol.Version (currently "40"). Revision 29
requires the target's 16-byte instance_id on administrative AddLearner
requests; revision 28 clients that omit it are incompatible. Missing,
invalid, duplicate, or different revisions fail with FailedPrecondition before
business handler execution. This applies to unary and streaming Bucket, Cluster,
and Restore operations, including internal forwarding. Discovery, gRPC health,
and reflection remain available without this declaration. This is independent
of release SemVer and commits; there is no legacy negotiation or bypass.
Discovery's ServerInfo.protocol_version and the flat JSON response from
GET /_info (protocolVersion) expose the server revision. HTTP callers do not
need gRPC metadata. The gate does not version the HTTP API or Raft/storage
formats. See the service protocol contract
for client setup, restore behavior, failure limitations, and revision changes.
Discovery also returns the committed ClusterPolicy when initialized, so clients
can use effective metadata ceilings instead of compiled default estimates.
The five v3 Numscript library operations declare their existing optional bearer
authentication and 401/403 failures. Anonymous scopes and auth-disabled mode remain
supported. Middleware failures contain plain text; controller failures can contain
the usual JSON errorCode/errorMessage envelope. Clients dispatch using both
status and media type. The HTTP response wrapper preserves an explicit content
type so a plain-text scope denial is not mislabeled JSON.
The 24 non-bulk Apply write operations declare JSON 429 responses for disk gating
and authoritative identifier exhaustion. See the evidence-backed
route/status/body matrix.
No 429 retry interval is supplied. WRITES_BLOCKED_DISK_FULL can clear with disk
pressure; SEQUENCE_EXHAUSTED is permanent for the allocator. Bulk keeps its
distinct processing envelope, and reads gain no speculative 429 declarations.
The v2 reference at d47ba1746cec2173d84eab8ec575be196eae0837
(internal/api/common/errors.go, openapi.yaml) has the familiar JSON
errorCode/errorMessage error envelope, which v3 preserves for domain failures.
Its database-client saturation maps to 503, not the v3 disk/sequence 429 reasons;
these different server semantics do not warrant forcing equivalent statuses.
The inspected v2 specification has no matching versioned Numscript library
routes or service-local 401/403 declarations. V3 scope failures follow its own
JWT/anonymous policy; no new endpoint or authorization requirement is introduced.
| Feature | POC | Original | Notes |
|---|---|---|---|
| Transactions (Write) | |||
| Create transaction (postings) | ✅ | ✅ | |
| Create transaction (numscript) | ✅ | ✅ | |
| Revert transaction | ✅ | ✅ | |
Revert with force |
✅ | ✅ | |
Revert with atEffectiveDate |
✅ | ✅ | |
| Revert by reference | ❌ | ❌ | Not supported — only numeric id targets are accepted |
| Set/delete tx metadata by reference | ❌ | ❌ | Not supported — only numeric targetId is accepted |
Create transaction with force |
✅ | ✅ | Bypasses balance checks |
| Transactions (Read) | |||
| Get transaction by ID | ✅ | ✅ | |
| List transactions | ✅ | ✅ | HTTP GET /v3/{ledgerName}/transactions and gRPC stream: cursor pagination, startDate/endDate range, and the generic filter (source/destination/address, reference, id, metadata) |
| Metadata | |||
| Save account metadata | ✅ | ✅ | |
| Delete account metadata | ✅ | ✅ | |
| Save transaction metadata | ✅ | ✅ | |
| Delete transaction metadata | ✅ | ✅ | |
| Save ledger metadata | ✅ | ❌ | New in v3 |
| Delete ledger metadata | ✅ | ❌ | New in v3 |
| Bulk | |||
| Bulk CREATE_TRANSACTION | ✅ | ✅ | |
| Bulk ADD_METADATA | ✅ | ✅ | |
| Bulk REVERT_TRANSACTION | ✅ | ✅ | |
| Bulk DELETE_METADATA | ✅ | ✅ | |
| Bulk atomic | ✅ | ✅ | System-level atomicity (cross-ledger) |
| Bulk continueOnFailure | ✅ | ✅ | |
| Ledger | |||
| Create ledger | ✅ | ✅ | HTTP + gRPC accept the full model: initialSchema, accountTypes (name/pattern/persistence/segmentTypes), defaultEnforcementMode |
| Create mirror ledger | ✅ | ❌ | HTTP or PostgreSQL source |
| Promote mirror ledger | ✅ | ❌ | Mirror → Normal mode |
| Delete ledger | ✅ | ✅ | |
| Get ledger | ✅ | ✅ | |
| List ledgers | ✅ | ✅ | |
| Account Types | |||
| Add account type | ✅ | ❌ | Full model over HTTP + gRPC: name, pattern, persistence, segmentTypes |
| List account types | ✅ | ❌ | List all types for a ledger (HTTP + gRPC; includes persistence + segmentTypes) |
| Get account type | ✅ | ❌ | Get details of a specific type |
| Remove account type | ✅ | ❌ | Remove a type from a ledger |
| Accounts (Read) | |||
| Get account | ✅ | ✅ | Includes volumes per asset |
| List accounts | ✅ | ✅ | Supports rich boolean filter (metadata equality/range/existence, address) with schema validation and cursor pagination. No date filtering or date ordering — see account date fields |
| Get account balances | ✅ | Included in account volumes | |
| Get account volumes | ✅ | ✅ | Returns input/output/balance per asset |
| Analyze accounts | ✅ | ❌ | Suggest Chart of Accounts from address patterns |
| Aggregate volumes | ✅ | ✅ | Per-asset aggregated volumes for filtered accounts (direct RPC, no prepared query needed) |
Account date fields (firstUsage/insertionDate/updatedAt) |
❌ | ✅ | Intentionally removed — not returned, not filterable, not paginatable. Replacement: typed datetime account metadata + index (details) |
| Logs | |||
| List logs | ✅ | ✅ | gRPC stream, supports --filter 'ledger == "foo"' for per-ledger listing (opt-in index) |
| Import/Export | |||
| Import logs | ✅ | Interface defined but not implemented | |
| Export logs | ✅ | Interface defined but not implemented | |
| Idempotency | |||
| Idempotency key | ✅ | ✅ | |
| Reference Uniqueness | |||
| Unique reference validation | ✅ | ✅ | Per-ledger uniqueness, HTTP 409 on conflict |
| Skip-on-conflict opt-in | ✅ | ❌ | Per-entry skippableReasons: ["TRANSACTION_REFERENCE_CONFLICT"] on the bulk endpoint → the entry's BulkResult.data carries an OrderSkippedResponse shape instead of the normal Transaction. The unitary POST endpoint intentionally does NOT expose the opt-in (a single-tx caller can catch the 4xx directly); gRPC clients can set LedgerApplyRequest.skippable_reasons on any Apply. |
| Numscript Library | |||
| Save numscript (versioned) | ✅ | ❌ | Per-ledger, immutable append-only, explicit full semver (e.g. "1.0.0"). Re-saving a version returns NUMSCRIPT_VERSION_ALREADY_EXISTS |
| Get numscript (by version) | ✅ | ❌ | Per-ledger, ?version=1.0.0; empty/latest = greatest stored semver; partial selectors (1, 1.2) allowed |
| List numscripts | ✅ | ❌ | Per-ledger, greatest version of each |
| List numscript versions | ✅ | ❌ | Per-ledger, GET .../numscripts/{name}/versions, current latest + every stored version |
| Audit Log | |||
| Audit log (success + failure) | ✅ | ❌ | Replicated via Raft, stored in Pebble |
| List audit entries | ✅ | ❌ | GET /v3/_/audit-entries (HTTP) + gRPC stream. Bucket-wide; pageSize/cursor/reverse (pagination) + a bare-audit-field filter expression (outcome, ledger, seq, proposal_id, timestamp, log_seq, caller_subject, order_type, idempotency_key; the last supports ==, in, and ^= prefix matching), resolved against the audit query target in both the textual and the structured JSON form ({"$match":{"outcome":"failure"}}; the ^= prefix is textual-only) — EN-1549 replaced the old audit[...] namespaced syntax — see Filter input formats |
| Get audit entry by sequence | ✅ | ❌ | GET /v3/_/audit-entries/{sequence} (HTTP) + gRPC. Populates per-order items |
| Audit log disable/enable | ❌ | ❌ | Not implemented |
| Error Handling | |||
| Structured gRPC error codes | ✅ | ✅ | BusinessError with ErrorInfo details |
| Security | |||
| Request signing (Ed25519) | ✅ | ❌ | Envelope pattern with signed_payload |
| Dynamic key management | ✅ | ❌ | Register/revoke/list keys via gRPC API (bootstrap: first key unsigned) |
| Require signatures | ✅ | ❌ | Optional enforcement via signing require API call |
| Maintenance | |||
| Maintenance mode | ✅ | ❌ | Block client business writes; leader-internal policy reconciliation remains available unless drained. Raft-replicated flag, dual check (admission + FSM) |
| Store Operations | |||
| Store metrics | ✅ | ❌ | Pebble storage metrics |
| Store integrity check | ✅ | ❌ | Hash chain + derived data verification |
| Store backup | ✅ | ❌ | Point-in-time Pebble backup as tar archive |
| Filesystem disk usage | ✅ | ❌ | Node-local gRPC/CLI diagnostic with per-volume validity, server-computed sample age, last observation time, and diagnostic error |
| Index status | ✅ | ❌ | Read index builder progress (lag, file size) |
| Store restore | ✅ | ❌ | Upload backup, validate, preview, finalize (--restore mode) |
| Prepared Queries | |||
| Create prepared query | ✅ | ❌ | Reusable parameterized filter queries |
| Update prepared query | ✅ | ❌ | |
| Delete prepared query | ✅ | ❌ | |
| List prepared queries | ✅ | ❌ | |
| Execute prepared query (list) | ✅ | ❌ | Returns matching entities with cursor pagination, ascending or descending (reverse); validates filters against metadata schema |
| Execute prepared query (aggregate) | ✅ | ❌ | Returns aggregated volumes per asset; validates filters against metadata schema |
| User-Configurable Indexes | |||
| Create index | ✅ | ❌ | Opt-in address, metadata, reference, timestamp, inserted-at, or account-asset indexes per ledger. HTTP: POST /v3/{ledger}/indexes; gRPC: Apply(CreateIndex) |
| Drop index | ✅ | ❌ | Remove an index from a ledger. HTTP: DELETE /v3/{ledger}/indexes/{canonicalId}; gRPC: Apply(DropIndex) |
| Get index | ✅ | ❌ | Fetch a single registry entry. HTTP: GET /v3/{ledger}/indexes/{canonicalId}; gRPC: GetIndex |
| Get index status | ✅ | ❌ | Per-index backfill cursor + per-replica IndexVersionState. HTTP: GET /v3/{ledger}/indexes/{canonicalId}/status; gRPC: GetIndexEntryStatus |
| Inspect index | ✅ | ❌ | Explore values of a metadata index (distinct values, facets, summary). HTTP: GET /v3/{ledger}/indexes/{canonicalId}/inspect; gRPC: InspectIndex. HTTP accepts summary (default), distinctValues, its deprecated distinct-values alias, and facets; OpenAPI documents the alias in the description but omits it from the enum to avoid generated SDK enum collisions. Supplied pageSize must be 1..10000 even for summary, with omission preserving the paginated default of 100. Explicitly empty/unsupported modes or empty/invalid sizes return 400 before backend invocation (EN-2905). This HTTP limit does not establish a gRPC inspection cap. |
| List indexes | ✅ | ❌ | View all indexes with build status and backfill progress. HTTP: GET /v3/{ledger}/indexes (per-ledger), GET /v3/_/indexes?scope=all|bucket (bucket-wide); gRPC: BucketService.ListIndexes, scoped ALL / BUCKET / LEDGER |
| Aggregated index status | ✅ | ❌ | Cluster-wide progress (LastIndexedSequence, LastLogSequence, Lag, IndexFileSize) + IndexEntry list. HTTP: GET /v3/_/indexes/status?ledger=; gRPC: GetIndexStatus |
| Volumes (responses) | |||
| postCommitVolumes | ✅ | ✅ | On every transaction (create, revert, get, list, prepared-query); immutable per-transaction snapshot |
| preCommitVolumes | ❌ | ✅ | Intentionally removed |
| postCommitEffectiveVolumes | ❌ | ✅ | Intentionally removed |
| preCommitEffectiveVolumes | ❌ | ✅ | Intentionally removed |
Legend: ✅ Implemented |
V2 and v3 HTTP responses emit decimal JSON number tokens for posting amounts,
volume input/output and balances by default. Formance-Bigint-As-String opts
into canonical decimal strings for all of these values. Both recognize
case-insensitive true, yes, y and 1, without trimming whitespace.
The v2 reference is internal/api/v2/views.go (needBigIntAsString, transaction,
account, log and aggregate renderers), verified on v2/main at
d47ba1746cec2173d84eab8ec575be196eae0837; native values there use math/big.Int.
V3 applies this contract to transaction create/get/list/revert, account get/list,
aggregate volumes, transaction-analysis statistics, bulk (including partial
failures), ledger/system logs, and prepared-query cursors/aggregates. V3 posting inputs accept both integer tokens
and canonical unsigned decimal strings regardless of the response header, with
the existing uint256 bound. Volume input/output and balances retain their
arbitrary-precision bounds and signedness. V3 selects response encoding by
commonpb.Uint256, commonpb.BigUint and commonpb.SignedBigInt, including
nested and future nonmonetary fields, rather than by endpoint or monetary field
name. This generalization preserves v2 monetary behavior; v2 has no equivalent
contract for these v3 message types. Ordinary integer IDs/counts, typed metadata
and other explicit string projections retain their existing contract. Shared
HTTP writers negotiate the response, while custom nested projections forward
options. Request decoding is unchanged.
The OpenAPI schemas expose number/string unions and the reusable header on
every affected operation. Generated SDK operation tests in tests/sdk/ retain
exact opt-in strings rather than coercing them into JavaScript numbers.
CLI/events and protobuf wire/storage/audit encodings retain their previous
representations. This intentionally replaces v3's previous always-string HTTP
volume output while preserving its field shapes and color dimension.
EN-1790 aligns the nested ledger-log JSON with v2 where the existing v3 data
model permits a simple projection. Both use type to identify a payload held
directly in data, and metadata logs use targetType with targetId (string
for an account, unsigned integer for a transaction, including zero). V3 gives
each of its 13 ledger-log variants a distinct discriminator; clients do not
need to inspect a second oneof wrapper inside data.
| Aspect | V2 | V3 |
|---|---|---|
| Created transaction | data.transaction |
data.transaction |
| Metadata target | targetType + targetId |
targetType + targetId |
| Metadata values | Strings | Typed values, including integers, booleans and null |
| Reversal | Original transaction and compensation | revertedTransactionId and revertTransaction |
| Post-commit volumes | Account → asset → volumes | Account → volume entries with asset and color |
| Enclosing response | Ledger log | System log containing a ledger log under payload.apply.log |
The same v3 projection is used by HTTP get/list logs, prepared-query logData,
JSON events, and ledgerctl JSON/YAML output. Global-log and event envelopes
remain specific to v3. Protobuf RPCs, persisted data, audit hashing, and the
separate ClickHouse/Databricks analytical projection are unchanged.
The OpenAPI data object explicitly allows additional properties (EN-2685).
Generated SDK decoders must preserve the operation payload both directly and
inside SystemLog.payload.apply.log; the schema does not exhaustively type
each operation variant.
SystemLog.payload also explicitly permits additional properties (EN-2781).
The existing typed apply.log remains available; all other system variants
and future siblings retain their nested JSON as open-ended values. The same
contract applies to single-log, list-log and prepared-query LOGS response
decoding. These global system operations have no v2 ledger-log equivalent;
the nested ledger log keeps its existing v2-style type/data structure.
EN-1634 separately owns credential-safe read projections.
The same explicit additional-properties policy covers other opaque v3 objects
(ledger metadata schemas, transaction account metadata, audit/signature fields,
event sinks/statuses, signing keys and index responses). This changes the SDK
schema contract without changing server payloads, routes or v2 compatibility.
The intentionally empty DropAction remains exempt.
This replaces the earlier unreleased v3 payload wrappers and shared
SET_METADATA fallback discriminator. This is an output contract; decoding the
projection back into internal Go log types is not supported by this contract.
See the ledger-log JSON output contract
for the full discriminator list and the information retained in the projection.
Endpoint: POST /v3/{ledgerName}/transactions
Features:
- ✅ Creation with direct postings
- ✅ Creation with Numscript script
- ✅ Numscript variables support
- ✅ Balance verification (insufficient funds)
- ✅
forceoption (bypass balance checks) - ✅ Transaction metadata
- ✅ Account metadata in the same request
- ✅ Transaction reference
- ✅ Custom timestamp
- ✅ Idempotency key
Numscript Experimental Features (available, require #![feature(...)] opt-in):
- ✅ Account interpolation (dynamic addresses like
@escrow:$order_id) - ✅ Asset colors — promoted to first-class posting field. Postings carry
color: stringand balances are strictly segregated per(account, asset, color). The empty color is the uncolored bucket and is itself segregated from every colored bucket. Color values match^[A-Z]*$and are immutable once carried by funds. See "Color of money semantics" below. - ✅
get_amount()/get_asset()functions - ✅ Mid-script function calls (balance queries during execution)
- ✅
oneofselector (conditional routing) - ✅
overdraft()function (dynamic overdraft calculation)
Color of money semantics (new in this POC):
Posting.coloris exposed on every read/write path. Direct postings accept the new field as the optional fifth component of the--posting source,destination,amount,asset[,color]syntax onledgerctl transactions create.Account.volumesis a deterministic sorted list of{asset, color, volumes}entries. The HTTP query parameter?collapseColors=trueonGET /{ledger}/accounts/{address}sums every colored bucket of the same asset into a single entry undercolor: "".AggregatedVolume.coloris set on every entry returned byGET /{ledger}/volumes. The same?collapseColors=trueflag collapses the result to one entry per(asset, precision).- The double-entry invariant holds per
(asset, color)bucket: each segregated bucket is its own conservation universe. - Numscript
source = @acc \ "RED"produces aPostingwithColor = "RED"and only draws from the matching bucket. Spending more than the bucket holds returnsErrInsufficientFundseven when other colored or uncolored buckets have plenty.
See Numscript Guide for complete documentation.
Endpoint: POST /v3/{ledgerName}/transactions/{transactionId}/revert
Features:
- ✅ Standard revert
- ✅
forceoption (ignore insufficient balances) - ✅
atEffectiveDateoption (use original transaction timestamp) - ✅ Revert metadata (typed values — string, integer, boolean — preserved losslessly; unsupported values rejected with
400 INVALID_REQUEST) - ✅ Verification that transaction is not already reverted
Optional HTTP body. A missing or empty revert body keeps the default options.
A supplied JSON body is decoded regardless of Content-Length, including
HTTP/1.1 chunked requests; force, atEffectiveDate and metadata reach the
same Apply payload for known and unknown lengths. Malformed or truncated JSON
returns 400 INVALID_REQUEST before Apply, including extra values or non-whitespace
after the first value. An option of the wrong JSON type (for example
"atEffectiveDate": "true", "force": 1 or a non-object metadata) also returns
400 INVALID_REQUEST before Apply instead of falling back to the default; a JSON
null option keeps its default. The complete body, including trailing whitespace, counts
towards the 4 MiB limit (413 BODY_TOO_LARGE), independently of framing.
Navigable revert relationship. The revert link is a first-class part of the
transaction representation (GET/list), not metadata — the platform never writes
com.formance.spec/* keys. A transaction exposes:
reverted(bool) andrevertedAt(timestamp) — set on the reverted original.revertedByTransactionId— on the reverted original, the id of the compensating transaction.revertsTransactionId— on the compensating transaction, the id of the original it reverts.
revertedAt is the compensating transaction's effective timestamp (so under
atEffectiveDate it equals the original's timestamp). All three are derived from
the structural TransactionState, so they hold identically after replay/backfill
and on the mirror revert path.
Both reverted and revertedAt are queryable (upstream v2 parity). reverted is
always available (served from the reversion bitset, no index); revertedAt requires
the reverted-at builtin index. revertedByTransactionId/revertsTransactionId are
navigable in the representation but not queryable (v3-only, no parity baseline).
Ledger v3 allows a transaction that reverts another transaction to be reverted once. The same rule applies to every transaction: a successful revert creates a new compensating transaction, and the target cannot be reverted again. Reverting the compensation does not clear the original transaction's reverted status. The usual balance checks and other revert validations still apply.
For example, transaction 648 reverts 645, and transaction 649 reverts 648.
After both operations, the relationship fields are:
| Transaction ID | reverted |
revertsTransactionId |
revertedByTransactionId |
|---|---|---|---|
645 |
true |
absent | 648 |
648 |
true |
645 |
649 |
649 |
false |
648 |
absent |
Requests to revert 645 or 648 again fail with
TRANSACTION_ALREADY_REVERTED. Transaction 649 can itself be reverted, creating
another transaction. A transaction can therefore both revert an earlier
transaction and be reverted by a later one.
This behavior is retained for payment flows such as chargebacks. When a dispute reverses an initial booking, winning the dispute can require reversing that compensation. Further presentments and dispute outcomes can extend the chain. Rejecting all reverts of compensating transactions would prevent this workflow. Ledger records each compensation and preserves the links between transactions.
Applications and agents must tie each revert to a business event and define when the workflow stops. The once-per-transaction rule prevents a second revert of the same target, but does not prevent an application from repeatedly reverting each new compensation.
The current implementation is
processRevertTransaction.
Endpoints:
POST /v3/{ledgerName}/metadata- Save ledger metadata (new in v3)DELETE /v3/{ledgerName}/metadata/{key}- Delete ledger metadata (new in v3)POST /v3/{ledgerName}/accounts/{address}/metadata- Save account metadataDELETE /v3/{ledgerName}/accounts/{address}/metadata/{key}- Delete account metadataPOST /v3/{ledgerName}/transactions/{transactionId}/metadata- Save transaction metadataDELETE /v3/{ledgerName}/transactions/{transactionId}/metadata/{key}- Delete transaction metadataGET /v3/{ledgerName}/metadata-schema- Get metadata schema (per-field declared type)PUT /v3/{ledgerName}/metadata-schema/{targetType}/{key}- Set/change metadata field typeDELETE /v3/{ledgerName}/metadata-schema/{targetType}/{key}- Remove metadata field type declaration
All metadata-key path parameters above use one URL-decoding pass, including
schema PUT/DELETE. A JSON key formance.com/reviewed is addressed with
formance.com%2Freviewed; double-encoded %252F retains a percent-containing
key and is rejected by metadata admission with HTTP 400. Canonical index IDs,
account addresses, numscript names, prepared-query names, account-type names
and events sink names use the same single-decoding rule: users%3Aa selects
the account users:a.
Ledger metadata is stored separately from ledger configuration (LedgerInfo) and is populated at read time when calling GET /v3/{ledgerName} or GET /v3/ (list ledgers). It uses the same typed value system as account/transaction metadata.
Metadata size contract (EN-1829): Direct HTTP, public gRPC, bulk and mirror
ingest share the same limits; Numscript-produced metadata is checked during FSM
apply after merging it with caller metadata. Limits bound metadata carried or
produced by one command, not an entity's accumulated stored metadata across
successive writes. A command is one atomic Raft proposal (ApplyBatch or mirror batch), including
all entities and orders it contains.
The effective limits are fields of the Raft-replicated common.ClusterPolicy:
| Policy field | Default | Bound |
|---|---|---|
metadata_max_entries_per_entity |
128 | Entries per entity |
metadata_max_key_bytes |
256 | Bytes per key, including deletion/schema keys |
metadata_max_value_bytes |
16384 | Measured bytes per value |
metadata_max_entity_bytes |
65536 | Key and value bytes per entity |
metadata_max_command_bytes |
1048576 | Key and value bytes across the command |
Mirror workers enforce the same ceilings on translated batches before proposal, and mirror FSM apply rechecks the committed policy before mutation. A rejected batch retains its applied cursor for retry. Direct account, transaction and ledger metadata saves, and reversals, also recheck non-empty input and the proposal-wide byte budget at apply, using the current committed policy rather than the policy observed during admission. Metadata deletion and field-type set/remove orders also recheck bare keys and the aggregate proposal budget before mutation.
OpenAPI documents these configurable ceilings in descriptions rather than fixed
maxLength or maxProperties constraints, so clients can use the effective
replicated policy even when operators raise the defaults.
String and null-original values use their UTF-8 byte length; integer, unsigned
integer and datetime values count as 8 bytes, and booleans as 1 byte. These are
accounting weights, not protobuf or JSON wire sizes. Zero does not mean unlimited:
all ceilings must be positive, key/value ceilings must not exceed the entity
ceiling, and the entity ceiling must not exceed the command ceiling. Changing
the startup flags requires an increased --cluster-policy-revision to update
the committed policy.
Exceeding a ceiling returns HTTP 400 / gRPC InvalidArgument, reason
METADATA_LIMIT_EXCEEDED, with dimension, limit and actual details.
dimension is entries, key, value, entity or command; counts apply to
entries and bytes to the other dimensions. Retrying the same oversized payload
does not repair the rejection. See the metadata limits contract
for enforcement and configuration details. These incompatible service semantics
and the required policy fields increment the service protocol from revision 7
to 8.
Endpoint: POST /v3/{ledgerName}/bulk
Supported actions:
- ✅
CREATE_TRANSACTION - ✅
ADD_METADATA(account and transaction) - ✅
REVERT_TRANSACTION - ✅
DELETE_METADATA(account and transaction)
Options:
- ✅
continueOnFailure- Continue even on error - ✅
atomic- Submit all operations as one batch for the URL's ledger - ✅
Idempotency-Keyheader - Optional identity of the whole atomic batch - ✅ Per-element
ik- Identity of each sequential operation; header ignored
HTTP bulk actions all inherit the URL's ledger name. The underlying service
batch can span ledgers, but the HTTP bulk operation cannot. In atomic mode,
per-element ik values are ignored; continueOnFailure controls the business
error status without allowing partial application of the batch.
SDK error contract: Processing failures retain the data array,
including committed successes, the failed element and aborted later elements.
Business statuses 400/404/409 are suppressed to 200 only when
continueOnFailure=true. Processing 401/403/429/500/503 surface unconditionally;
503 retains Retry-After: 1. OpenAPI declares BulkErrorResponse as an anyOf
union of the bulk envelope and ordinary ErrorResponse where both are accepted,
and includes the optional batch idempotency header. Empty successful batches
currently emit {}. Invalid JWTs return a plain-text 401 from middleware;
per-element scope prechecks return JSON 401/403. Byte-limit decoding errors
return 400; the configured element-count limit returns 413. Panic recovery
returns an ordinary JSON 500. See the
complete response matrix.
Generated SDK callers can inspect all element fields and error headers; this
change does not alter automatic retry defaults. A failed request does not prove
noncommit, and sequential retries must preserve per-element identities.
v2 comparison: Verified against v2 source revision
d47ba1746 (internal/api/v2/controllers_bulk.go,
internal/api/bulking/{bulker,elements,handler_json}.go, their tests and
openapi/v2.yaml). Both versions use atomic, continueOnFailure, and the
element field ik; v2's JSON result also exposes responseType/data/logID and
element errors. V2 returns 400 for an element failure and declares bulk bodies
for 200/400. V3 uses the existing status classifier to expose 404/409/429/5xx
with the same element envelope. V2 passes each element's identity to its
controller, including inside its database transaction, and its bulk operation
does not declare or read a batch-level header. V3's single atomic proposal has
one identity, so the new typed header documents that intentional difference.
The v2 schemaVersion/parallel options are not added to v3 by this correction.
Endpoints:
POST /v3/{ledgerName}- Create a ledger. Optional body fields:metadata,mode,mirrorSource,defaultEnforcementMode,initialSchema(metadata field types), andaccountTypes(full account-type model — name/pattern/persistence/segmentTypes). These mirror the gRPCCreateLedgerRequest. Initial typed metadata is atomic with creation in normal and mirror modes and requires onlyledger:WriteLedger; later saves requireledger:WriteMetadata. JSON null entries are omitted. Schema declarations do not enforce write-time value types. See the creation contract.DELETE /v3/{ledgerName}- Delete a ledgerGET /v3/{ledgerName}- Get ledger info (read)GET /v3/- List all ledgers (read)
Endpoints:
GET /v3/{ledgerName}/account-types- List all account types for a ledgerGET /v3/{ledgerName}/account-types/{typeName}- Get details of a specific account typePOST /v3/{ledgerName}/account-types- Add a new account type (body: name, pattern, optionalpersistenceandsegmentTypes)DELETE /v3/{ledgerName}/account-types/{typeName}- Remove an account type
Features:
- ✅ Pattern-based account address validation (e.g.,
users:{id}:checking) - ✅ Variable segments with optional regex constraints (e.g.,
{iban:^[A-Z]{2}[0-9]{14}$}) - ✅ Ledger-level default enforcement mode: STRICT (reject) or AUDIT (accept without reporting)
- ✅ Longest-match / highest-specificity resolution when multiple types match
- ✅
worldaccount always passes validation - ✅ Account types can be set at ledger creation time or added later
- ✅ Persistence modes:
NORMAL(default),EPHEMERAL(purged when zero balance),TRANSIENT(never persisted, must be zero at end of batch)
Exact transaction address filters use retained historical mappings for any, source and destination roles, independently of current account existence. An EPHEMERAL volume purge must therefore preserve funding and draining transaction matches. Address prefixes continue enumerating current accounts; historical prefix expansion is not supported. Indexed transaction metadata and reference searches retain their existing behavior. Account-target exact lookup still requires current account existence.
This restores the historical-search principle of v2: release/v2.4 at
21d1e51378b4dbb6b9858313cafc2d8bab62f329,
internal/storage/ledger/transactions.go, records posting sources/destinations
on transactions and filters those arrays without probing current accounts.
V3 retains its existing routes, envelopes, filter syntax, index requirements,
errors and pagination. No v2 EPHEMERAL lifecycle equivalence is implied. Existing
omitted v3 mappings need the explicit repair described in
Upgrades; restarting alone does not reconstruct them.
Endpoint: GET /v3/{ledgerName}/transactions/{transactionId}
Features:
- ✅ Get transaction by ID
- ✅ Returns transaction details (postings, metadata, timestamp, reference)
- ✅ Works from any node (leader or follower)
- ✅ Returns 404 for non-existent transactions
Response includes:
- Transaction ID
- Postings (source, destination, amount, asset)
- Metadata
- Timestamp
- Reference (if set)
CLI command:
ledgerctl transactions get --ledger <ledger-name> --id <transaction-id>Mirror mode enables one-way synchronization from an existing v2 ledger into a v3 ledger. The mirror ledger is read-only until promoted to normal mode.
Create a mirror ledger: POST /v3/{ledgerName}
Request body includes mode ("MIRROR") and a mirrorSource object specifying the source configuration. mode accepts only "NORMAL" (the default when omitted) or "MIRROR", case-sensitive; any other value is rejected with HTTP 400 (INVALID_REQUEST) and no ledger is created.
Source types:
- HTTP (
type: "http") — Polls the v2 API endpointGET /v2/{ledger}/logs. Fields:baseUrl,oauth2ClientId,oauth2ClientSecret,oauth2TokenEndpoint,oauth2Scopes(optional, for OAuth2 client credentials authentication). - PostgreSQL (
type: "postgres") — Reads directly from the v2 ledger's PostgreSQL database. Fields:dsn. AWS RDS IAM authentication is provisioned via the operatorLedgerCRD (mirrorSource.postgres.awsIamAuth.region+ optionalassumeRoleArnfor cross-account / multi-tenant mirrors) — seemisc/operator/api/v1alpha1/ledger_crd_types.go. The mirror pod mints SigV4 tokens per connection from the ambient AWS credential chain (IRSA on EKS).
If type is omitted, defaults to "http".
HTTP source validation: Creation requires baseUrl to parse as an absolute
HTTP(S) URL with a nonempty hostname. Invalid input returns HTTP 400 / gRPC
InvalidArgument (VALIDATION), identifying mirrorSource.http.baseUrl without
echoing credentials. Rejection occurs before Raft, audit/ledger persistence and
worker startup, and does not probe the remote source.
Asset mappings: mirrorSource.assetMappings is an optional list of
{sourceAsset, asset, color} entries. Each entry maps one v2 asset, matched
exactly, to a v3 asset and color, so v2 assets that v3 cannot represent
(USDT_ETHEREUM/6, USD/0) can be mirrored. Mappings apply after
rewriteRules. Creation rejects an invalid list with HTTP 400 / gRPC
InvalidArgument (reason VALIDATION), whose message names the
first offending entry. A valid list has at most 256 entries, each
with a unique non-empty sourceAsset of at most 64 bytes, a valid v3 asset,
a color matching ^[A-Z]{0,32}$, and a unique (asset, color) target. The
list is fixed at creation and is returned in mirrorSource.assetMappings by
GET /v3/{ledgerName}. See
asset mappings.
Write guard: All direct write operations (create transaction, save metadata, delete metadata, revert transaction) are rejected on mirror-mode ledgers with HTTP 409 (LEDGER_IN_MIRROR_MODE) or gRPC FailedPrecondition.
Sync progress: GET /v3/{ledgerName} returns a mirrorSyncProgress object for mirror ledgers with:
state:SYNCING(catching up with history) orFOLLOWING(up to date)cursor: Last ingested v2 log IDsourceLogCount: Latest known log ID in the v2 sourceremainingLogs: Number of logs remaining to sync (sourceLogCount - cursor)error: Most recent sync error (null if healthy)
Sync behavior: A background worker polls the source for v2 logs and replays them into the mirror ledger. Supported v2 log types:
NEW_TRANSACTION— Creates a transaction with postings and optional account metadataSET_METADATA— Sets metadata on an account or transactionREVERTED_TRANSACTION— Replays a revertDELETE_METADATA— Deletes a metadata key- Unknown log types are recorded as fill-gap entries (no-op for data, preserves log ID sequence)
Sync errors stall the mirror: a v2 log that cannot be translated stops
ingestion at that log. The cursor does not advance, the worker retries with
backoff, and mirrorSyncProgress.error is set. Its message is returned as
[redacted]; the reason is in the server's Mirror sync error log line. A
posting asset that is not a valid v3 asset and has no assetMappings entry is
such an error, as is an unmapped asset equal to an uncolored mapping target. A
mirror that already stores invalid assets refuses to ingest and logs
ledger contains assets ingested before asset-mapping support. Mirror source
configuration cannot be changed, so recovery is to delete the mirror ledger and
recreate it with the needed assetMappings or drop rewrite rules.
Promote a mirror ledger: POST /v3/{ledgerName}/promote
Converts the mirror ledger to normal mode. After promotion:
- The mirror worker is stopped
- The ledger accepts direct writes
- The
modechanges fromMIRRORtoNORMAL - The
mirrorSourceconfiguration is cleared
Promoting a non-mirror ledger returns HTTP 400 (LEDGER_NOT_IN_MIRROR_MODE) or gRPC FailedPrecondition.
gRPC: Both create-mirror and promote operations go through the Apply method using CreateLedgerRequest (with mode and mirror_source fields) and PromoteLedgerRequest.
The numscript library allows saving, retrieving, and managing reusable numscript programs with semver versioning.
Endpoints:
GET /v3/{ledgerName}/numscripts- List all numscripts (greatest version of each)GET /v3/{ledgerName}/numscripts/{name}?version=- Get a numscript by name (optional version selector)GET /v3/{ledgerName}/numscripts/{name}/usage- Get invocation count and last-used timestamp for a templateGET /v3/{ledgerName}/numscripts/{name}/versions- List version history (current latest + every stored version)PUT /v3/{ledgerName}/numscripts/{name}- Save an immutable version (explicit full semver)
Versioning:
Numscripts use semantic versioning (semver) with the format major.minor.patch (e.g. "1.0.0").
When saving a numscript via PUT /v3/{ledgerName}/numscripts/{name}, the request body includes:
content(required): The numscript source codeversion(required): An explicit full semver (e.g."2.0.0") creating a new immutable version. Fails with 409 if the version already exists, and with 400 (NUMSCRIPT_INVALID_VERSION) for an empty, partial, or"latest"value.
When retrieving a numscript via GET /v3/{ledgerName}/numscripts/{name}, the version query parameter selects which version to return. If omitted or empty, the latest version is returned.
When referencing a numscript from a transaction (scriptReference on POST /v3/{ledgerName}/transactions), version is required and accepts only the literal "latest" or an exact full semver. The read-only convenience selectors are rejected with 400 (NUMSCRIPT_INVALID_VERSION): the submitted selector is what the audit chain records, so an executable reference must name its version explicitly, and a partial selector (1, 1.2) would additionally need a Pebble scan that the FSM apply path cannot make.
Response schema (NumscriptInfo):
name(string): Numscript namecontent(string): Numscript source codeversion(string): Semver version (e.g."1.0.0")createdAt(string, date-time): Timestamp
Usage tracking (GET /v3/{ledgerName}/numscripts/{name}/usage):
Returns per-template invocation counters and the timestamp of the most recent invocation. Populated asynchronously by the usagebuilder subsystem, which tails the FSM audit chain and writes to a dedicated secondary Pebble store (<data-dir>/usage/). Values are eventually consistent with the FSM and may lag while the worker drains pending batches.
A never-invoked template returns a zero-valued response (not 404), so clients handle "never used" uniformly:
count(uint64): Number of times the template has been invoked.0means not yet invoked (or the usagebuilder has not caught up).lastUsed(string, date-time, nullable): Timestamp of the most recent invocation. Absent when count is 0.
On a fresh ledger the counter builds up organically from cursor=0.
Every filtered surface accepts one filter in either the textual filterexpr
grammar or the structured v2 JSON DSL; both compile to the same
*commonpb.QueryFilter. Both forms cover the same fields (reference,
reverted, id, insertedAt, revertedAt, logId, the audit fields, …); only
a few prefix operators remain textual-only. The full contract — parameter
classification, the field parity table and operator asymmetries, date coercion
(EN-1544), AND-combination, and the removed prefix/reference aliases
(EN-1540) — is documented once in
query-filter.md.
Prepared queries are reusable, named queries stored per-ledger. A nil filter is
a match-all definition for the selected target. HTTP create accepts omitted or
null filter; HTTP update treats omission as no change and null as filter
removal. Apply(UpdatePreparedQuery) uses a nil protobuf filter for removal.
Empty objects and empty textual expressions are invalid. Queries can be
executed in two modes: LIST (returns matching entity IDs with cursor
pagination, ascending by default and descending with reverse) and AGGREGATE_VOLUMES (returns aggregated volumes per asset for
matched accounts).
PreparedQueryFilterInput describes a structured QueryFilter or an inline
nullable string in OpenAPI 3.0 (EN-2685). Null belongs to the string alternative,
so the union needs no dummy empty-object model and does not rely on ignored
siblings beside a $ref. Generated create/update request serializers must
preserve structured and textual inputs and distinguish omission from null.
Endpoints:
POST /v3/{ledgerName}/prepared-queries— CreatePUT /v3/{ledgerName}/prepared-queries/{name}— Update filterDELETE /v3/{ledgerName}/prepared-queries/{name}— DeleteGET /v3/{ledgerName}/prepared-queries— ListPOST /v3/{ledgerName}/prepared-queries/{name}/execute— Execute
Supported filter types (QueryFilter):
| Filter | Target | Requires index |
|---|---|---|
FieldCondition — metadata string equality |
accounts, transactions | yes (metadata index) |
AddressMatch — prefix or exact address match |
accounts | no |
AndFilter / OrFilter / NotFilter |
— | depends on sub-filters |
ReferenceCondition — transaction reference exact match |
transactions | yes (reference builtin index) |
BuiltinUintCondition with TIMESTAMP — effective date range |
transactions | yes (timestamp builtin index) |
BuiltinUintCondition with INSERTED_AT — creation date range |
transactions | yes (inserted_at builtin index) |
BuiltinUintCondition with REVERTED_AT — revert date range |
transactions | yes (reverted_at builtin index) |
BuiltinUintCondition with ID — transaction ID range or equality |
transactions | no (direct range scan) |
RevertedCondition — transaction revert status (true/false) |
transactions | no (reversion bitset) |
AccountHasAssetCondition — accounts that have ever touched an asset |
accounts | yes (account-asset index) |
User-configurable indexes control which filters are available. Each index has a lifecycle: BUILDING (backfill in progress) → READY (queries enabled).
| Index type | CLI flag | Enables |
|---|---|---|
address |
--type address |
AddressMatch on transaction queries |
source-address |
--type source-address |
source-only address matching |
destination-address |
--type destination-address |
destination-only address matching |
metadata |
--type metadata --target … --key … |
FieldCondition on the specified field |
reference |
--type reference |
ReferenceCondition |
timestamp |
--type timestamp |
BuiltinUintCondition(TIMESTAMP) |
inserted-at |
--type inserted-at |
BuiltinUintCondition(INSERTED_AT) |
reverted-at |
--type reverted-at |
BuiltinUintCondition(REVERTED_AT) |
account-asset |
--type account-asset |
AccountHasAssetCondition — has asset <BASE>[/<PRECISION>] filter on account queries |
Filtering by transaction ID (
BuiltinUintCondition(ID)) and by revert status (RevertedCondition) are always available with no index required — the latter is served from the per-ledger reversion bitset.
Revert querying (upstream v2 parity). v2 queries transactions by both
revertedandreverted_at; v3 matches this:RevertedConditionfor the boolean andBuiltinUintCondition(REVERTED_AT)(behind thereverted-atindex) for the date range. The v3-only navigable idsrevertedByTransactionId/revertsTransactionIdare exposed in the representation but are not queryable (no v2 baseline); adding them would follow the same builtin-index recipe.
CLI commands:
# Create and use a reference index
ledgerctl indexes create --ledger my-ledger --type reference
ledgerctl indexes list --ledger my-ledger
# Create a prepared query that filters by reference
# (done via gRPC / HTTP — no direct CLI for query creation)Description: Import logs from another ledger for migration or synchronization.
Current status:
- Interface defined in
Ledger.Import(ctx, stream chan *commonpb.Log) error - Implementation returns
ErrNotFound
To implement:
- HTTP endpoint (probably
POST /v3/{ledgerName}/_import) - Log validation
- Sequential insertion with consistency verification
- Streaming support for large volumes
Description: Export all logs from a ledger for backup or migration.
Current status:
- Interface defined in
Ledger.Export(ctx, w ExportWriter) error - Implementation returns
ErrNotFound
To implement:
- HTTP endpoint (probably
GET /v3/{ledgerName}/_export) - Log streaming
- Output format (JSON lines, protobuf, etc.)
- Pagination/cursor for large volumes
Description: In the original ledger, transaction reference must be unique within a ledger.
Current status: Fully implemented.
- Transaction references are validated for uniqueness within a ledger using the attribute system
- References are stored as
TransactionReferenceValue(containing the transaction ID) keyed by[ledgerID][reference] - Duplicate references within the same ledger return HTTP 409 Conflict (
ErrTransactionReferenceConflict) - Empty references are allowed and not validated
- The same reference can exist in different ledgers
Description: Ability to add/modify metadata on a ledger after creation.
Current status: Fully implemented.
POST /v3/{ledgerName}/metadata- Add/modify metadataDELETE /v3/{ledgerName}/metadata/{key}- Delete a metadata key
These endpoints are documented in Section 3 (Metadata Management) above.
Both routes require ledger:WriteMetadata; ledger:write grants it under
the default scope mapping. SDK metadata-key parameters take the raw key, such
as formance.com/reviewed, and encode it as one path segment. Direct HTTP
clients perform that encoding themselves; the server decodes exactly once.
Description: Modify certain ledger parameters after creation (e.g., maintenanceInterval).
To implement:
PATCH /v3/{ledgerName}orPUT /v3/{ledgerName}/config
Description: the original ledger carries three date fields on every account —
firstUsage, insertionDate, updatedAt (internal/account.go:21-23 on
release/v2.4) — and declares all three filterable and paginatable
(internal/queries/resources.go:23-30, NewDateField().Paginated()). They back
cohort queries ("accounts created this month"), dormancy sweeps, and
date-ordered account pagination.
Current status: absent on every axis, and intentionally so (closed as a decision in ledger#2025).
- Not in the response. The
Accountschema isaddress/metadata/volumesonly (openapi.yml). The fields survive on the internal proto (misc/proto/common.proto,Account.first_usage/insertion_date/updated_at) but nothing in the write path stamps them, so the JSON marshaller always omits them. - Not filterable. The generated per-target validity table sets
ConditionKindBuiltinUint: falseforQUERY_TARGET_ACCOUNTS(internal/proto/commonpb/common_queryfilter_validity.pb.go), so no builtin date condition is valid on the accounts target.ConditionKindFieldistruethere, which is what the metadata-range replacement below rides on; what is gone is a builtin date field to point a condition at. The names are not in the DSL either —first_usagefalls throughdecodeMatchtoparseMetadataKeyand 400s as$match: unsupported field "first_usage". - Not paginatable. Account cursor pagination is by address.
Why it is removed rather than pending: the original ledger maintains
first_usage with a read-modify-write upsert — its UpsertAccounts statement
reads the stored row and lowers the value whenever the incoming write carries an
earlier one (internal/storage/ledger/accounts.go:170 on release/v2.4; the
statement and its aliases are v2 Postgres and have no counterpart in this
codebase). v3's admission
path deliberately stopped injecting prior values into the preload — the same
change that disabled MetadataCount
(internal/application/ctrl/controller_default.go:552-555). A first-touch
stamp would not strictly need that upsert (the FSM already distinguishes new
from pre-existing cells via isNewVolumeUpdate, off the preloaded prior value),
but it is still not a flag flip: accounts are addressed only as VolumeKey or
MetadataKey, both embedding AccountKey rather than being keyed by it, so
an account-level date is a new attribute key shape — pulling in the FSM write
path, checker prediction on a replay from genesis, backup/restore coverage, the
antithesis oracle, and an index if it is to be filterable. Typed datetime
account metadata covers the filtering use cases on machinery that already
exists, so the fields stay out. Date ordering is not replaced — see the read
side below.
Supported replacement — read side: declare an account metadata key as
datetime (SetMetadataFieldTypeCommand.type on the proto,
MetadataFieldTypeCommand as the OpenAPI schema); create an account metadata
index on that key; wait until that index reports current_version > 0 with
pending_version == 0 — there is no READY status on the wire, readiness is
per-replica version state, see
Index readiness and metadata schema retypes
— then range-filter it:
filter=metadata[first_usage] >= 1704067200000000
The operand is raw microseconds, not RFC3339: metadataRangeToProto
(internal/pkg/filterexpr/parser.go) parses metadata range operands with
strconv.ParseInt and rejects anything else with range operators only support integer values. The RFC3339 coercion of EN-1544 covers the builtin
date/timestamp fields, which are not valid on the accounts target at all.
Declaring the field datetime is an index and query contract, not a storage
conversion. CoerceToDeclaredType is called from the index builder
(internal/application/indexbuilder/schema_resolver.go) to encode forward-index
entries under the declared type; entity reads return the stored value verbatim
and are explicitly not routed through it, so GET .../accounts/{address} gives
back the RFC3339 string the client wrote. Write RFC3339, filter in microseconds,
and expect RFC3339 back.
This works on GET /v3/{ledgerName}/accounts and on
GET /v3/{ledgerName}/volumes (which compiles its filter for the accounts
target).
It replaces date filtering, not date ordering. GET /v3/{ledgerName}/accounts
takes pageSize and a cursor whose key is an account address, so rows come back
address-ordered and a metadata range only narrows that set. There is no sort
parameter and no date-keyed cursor, so a v2 client that paginated accounts in
date order has no server-side equivalent: it either sorts a filtered page
client-side or accepts address order. The two halves are covered separately:
tests/e2e/business/accounts_test.go for metadata range filtering on the
accounts target (int64 age), and
tests/e2e/business/datetime_metadata_index_test.go for the datetime
declaration, RFC3339 values in and integer-micro bounds out (transactions
target).
Supported replacement — write side: there is no set-if-absent.
SaveAccountMetadata is a blind overwrite, admission does not preload old
values, and Numscript's set_account_meta has no conditional form — so a rule
that stamps the key on every transaction yields last usage, not first. To
stamp exactly once without a client-side read, send the opening transaction
through the bulk endpoint (POST /v3/{ledgerName}/bulk, body = a JSON
array of entries) with a per-address reference and the skip opt-in —
skippableReasons sits on the entry, not inside data:
[
{
"action": "CREATE_TRANSACTION",
"skippableReasons": ["TRANSACTION_REFERENCE_CONFLICT"],
"data": {
"reference": "open:users:alice",
"postings": [{ "source": "world", "destination": "users:alice", "asset": "USD/2", "amount": 100 }],
"accountMetadata": { "users:alice": { "first_usage": "2024-01-01T00:00:00Z" } }
}
}
]The FSM arbitrates the reference in Raft order, so a second attempt carrying the same reference is skipped rather than overwriting.
That is a per-reference guard, not a per-account one, and the difference
decides whether the stamped value is correct. Uniqueness is scoped to
(ledger, reference) and binds nothing to the account, and the conflict check
runs before any metadata write. So if another transaction touches the account
first under a different reference, this request still succeeds afterwards and
stamps a later date; and if the chosen reference was already used for
something unrelated, the entry is skipped and the stamp never lands at all. The
recipe is correct only where the application reserves the open:<address>
namespace and makes this request the sole first-touch path for that account.
The envelope matters: the unitary
POST /v3/{ledgerName}/transactions deliberately does not expose
skippableReasons (internal/adapter/http/handlers_create_transaction.go), so
the same request there returns 409 on the second attempt instead of skipping.
gRPC callers set LedgerApplyRequest.skippable_reasons directly; the HTTP bulk
handler hoists the per-entry list onto that field. The transaction must produce
at least one posting (domain.ErrEmptyTransaction), so this is clean where
there is a real opening transaction and awkward where accounts appear
implicitly.
Migration from the original ledger — scope. Everything below is about
first_usage, which is the only one of the three with a reconstruction story
worth writing down. The other two are straight cut-over copies into typed
datetime keys of their own, and neither is recoverable afterwards in a way
worth relying on:
insertion_dateis stamped once, on insert, and never lowered — onrelease/v2.4only the insert arm sets it, to the incoming value or totransaction_date()when none is supplied. Copy it at cut-over or lose it; the v3 side has no first-insert concept to derive it from.updated_atmoves only when the account row is actually rewritten. The update arm runs only when the incoming row carries an earlierfirst_usagethan the stored one, or brings metadata the stored row does not already contain — so a later posting that changes no metadata and supplies no earlier date leaves the row, and the timestamp, untouched. It is not a last-touched marker. An application key stamped on every write is the closest v3 equivalent, but it is a different semantic: it moves where v2 would have stood still.
Nothing in the mirror path carries first_usage across. Copying the v2
value into the typed datetime metadata key at cut-over is the only lossless
option. Reconstruction after the fact is possible because history is permanent,
but it has to reproduce what v2 actually does, and v2 writes the field in two
distinct ways (release/v2.4):
- Established on insert. Whichever write first creates the account row sets
first_usageto the incoming value, or to the database'stransaction_date()when the write supplies none. A transaction supplies its effective date; a plainSaveAccountMetadatasupplies nothing and takestransaction_date(), so a metadata-only account does have afirst_usagewith no transaction behind it. - Lowered afterwards only by a write that supplies an earlier value. Two
callers supply one:
upsertTransactionAccounts(the transaction's effective date) and, on the import path only,importLog→UpdateAccountsMetadata(the log date).
A normal SaveAccountMetadata is not in that second group: it calls
UpsertAccounts with the date fields unset, LEAST ignores the NULL, and the
update arm's "incoming date is earlier" test is never true for it. So an
ordinary metadata save on an account that already exists does not move
first_usage at all.
The reconstruction that follows from this: take the minimum transaction
effective date over every transaction touching the address; where the account
has no transaction, fall back to the date of the log that first created it —
the closest recoverable proxy for the transaction_date() that v2 stamped; and
on a ledger populated by Import, also take the log dates of the replayed
metadata entries, which is the one case where a metadata save really can lower
the value. A transaction-only scan misses metadata-only accounts entirely.
On a mirrored ledger the metadata arm is not in the emitted log — read the
audit chain or the v2 log instead. processMirrorSavedMetadata is called
without the source date (processor_mirror.go passes entry.GetDate() to the
created- and reverted-transaction handlers only) and the emitted v3 ledger log
is stamped with the mirror's own apply time, so a January metadata save ingested
in September reads as September in the ledger log.
The date itself is retained, one level up. TranslateBatch
(internal/adapter/v2/translator.go) assigns entry.Date from the v2 log for
every translated entry kind, SET_METADATA included — the per-kind
translators do not set it, which is why reading translateSavedMetadata alone
is misleading — and the whole MirrorIngest order is persisted in
AuditItem.SerializedOrder, whose bytes are the order's business-intent
projection with only OrderTechnical stripped
(MarshalOrderBusinessIntent). So an audit-order scan recovers the source date
after the v2 source is gone; the v2 log is simply the cheaper read while it is
still there.
The transaction arm is unaffected: ingest requires a source date for created
and reverted transactions and rejects the entry without one, and a mirrored
transaction keeps its own v2 effective timestamp
(processMirrorCreatedTransaction preserves ct.GetTimestamp()).
Do not shortcut that through the new_kept_volumes log annotation. It
records the cells a log first materialized, which is a different set: a
backdated transaction against an already-materialized cell lowers v2's
first_usage but produces no new-kept entry, and a cell created and drained
within a single log is recorded as ephemeral_volumes instead, so an account
whose only activity is a pass-through never appears at all. The annotation is
also keyed per (account, asset, color) while first_usage is per address.
Description: In the original ledger, transaction creation responses include volumes before and after the commit:
postCommitVolumes- Volumes after transaction applicationpreCommitVolumes- Volumes before transaction applicationpostCommitEffectiveVolumes- Effective volumes after application (with effective timestamp)preCommitEffectiveVolumes- Effective volumes before application (with effective timestamp)
POC Status:
postCommitVolumesis part of every persisted transaction. It rides on thetransactionobject (not a sibling field), so create, revert, bulk, unitary get, list, and prepared-query results all expose the same value. It is an immutable historical snapshot captured at the transaction's sequence — the volumes (input/output) per(account, asset, color)tuple touched by the transaction, right after it applied — never recomputed from current balances.preCommitVolumes,postCommitEffectiveVolumes, andpreCommitEffectiveVolumesremain intentionally removed.
Response shape (the transaction object carries the field):
{
"id": 1,
"postings": [...],
"postCommitVolumes": {
"users:alice": [
{ "asset": "USD/2", "color": "", "input": "0", "output": "1000" }
],
"users:bob": [
{ "asset": "USD/2", "color": "", "input": "1000", "output": "0" }
]
}
}Impact on clients:
- Post-commit volumes are always returned; there is no request flag to toggle them (the former
expandVolumesflag is removed). - Each account maps to a flat list of
(asset, color)rows; the empty color is the uncolored bucket. preCommitVolumesand effective volumes remain removed; use dedicated read endpoints if needed.
POC: Strict balance verification by default. The force flag on transaction creation or revert bypasses balance checks, allowing accounts to go negative.
Original: Same behavior, but the original ledger has configuration options for "unbounded" accounts.
Status: ✅ Compliant (via force flag)
POC: The "world" account has infinite funds (universal source).
Original: Same behavior.
Status: ✅ Compliant
POC: Postings are reversed (source ↔ destination) AND the order is reversed.
Original: Same behavior.
Status: ✅ Compliant
POC:
- Supported via
Idempotency-Keyheader (HTTP) orApplyBatch.idempotency_key(gRPC) — keyed per atomic batch - System-level scope (not per-ledger)
- Hash-based content verification (BLAKE3)
- Stored in generation-based cache and persisted to Pebble
- Maximum key length: 256 characters (validated at admission)
Original: Same mechanism.
Status: ✅ Compliant
See Idempotency for detailed documentation.
POC: Index "readiness" is a per-replica concept driven by the
local IndexVersionState (current_version, pending_version), not
by a cluster-wide flag.
CreateIndexis strict: an existing(ledger, canonical IndexID)fails withINDEX_ALREADY_EXISTS(HTTP409, gRPCAlreadyExists), including while building or retyping. The registry and local build state remain unchanged. Retained batch-idempotency replay keeps returning its original result.CreateIndexregisters a new index atforward_encoding_version = 1and each replica starts a local backfill. When the backfill catches up to the global indexer cursor, the replica performs a local atomic switch (current_version0 → 1) in a single Pebble batch. There is no cluster-wideIndexReadyproposal — different replicas can be in different states at the same wall-clock moment.- Clients that need to gate on "this replica is ready to query" use
GetIndexStatusand wait forcurrent_version > 0withpending_version == 0; a RETYPE is complete oncecurrent_versionhas advanced past its pre-retype value (per-replica numbers are allocated from a local high-water mark and are not comparable toforward_encoding_version). Projection catch-up is automatic on indexed reads and is distinct from readiness. SetMetadataFieldType(retype) bumps the cluster-wideIndex.forward_encoding_version. Each replica then runs a local rewrite into the new versioned keyspace (pending_version), with live writes dual-writing into bothcurrent_versionandpending_versionuntil the local rewrite finishes and atomically switches. Queries served from the replica'scurrent_versionstay consistent throughout — no half-rewritten state is ever observable.
Client synchronization: EN-1946 removed the unreleased v3
ReadOptions.min_log_sequence surface. A linearizable indexed read now
captures Raft ReadIndex result R, opens its main snapshot at durable applied
index H >= R, and waits for only the projection it uses to certify H. A
stale read skips R but still aligns used projections to its fixed local H.
Native log/audit cursors remain internal fold and trimming state.
That Raft certificate does not guarantee that a schema rewrite has
completed or that the replica is serving the new encoding. The atomic switch
(current_version ← pending_version) is a separate per-replica
background event, gated internally on the read store catching up to
the FSM seq the rewrite observed. Queries against the replica
continue serving from current_version (the pre-retype encoding)
until that switch fires — see the previous bullet's "consistent
throughout" guarantee. Clients that need post-switch consistency
capture GetIndexStatus.IndexEntry.current_version before the
retype and poll until it has advanced past that value with
pending_version == 0 (per-replica numbers are local high-water
allocations, never comparable to forward_encoding_version), OR
rely on the eventual-consistency window (typically seconds)
inherent to background rewrites. pkg/actions ships this as
MetadataIndexCurrentVersion + WaitForMetadataIndexRewrite.
Original: No equivalent — the original ledger has no per-replica versioning and reaches "ready" via a single synchronously-applied schema migration.
Status: IndexEntry.current_version / pending_version), with no
cluster-wide ready flag on the wire.
Read endpoints comparison with the original ledger:
| Endpoint | POC | Original | Notes |
|---|---|---|---|
GET /v3/{ledgerName}/transactions/{id} |
✅ | ✅ | Get a transaction by ID |
GET /v3/{ledgerName}/transactions |
✅ | ✅ | List transactions: cursor pagination, startDate/endDate range, and the generic filter (reference selection via filter={"$match":{"reference":"..."}}) |
GET /v3/{ledgerName}/accounts |
✅ | ✅ | List accounts (rich boolean filter, cursor pagination). No first_usage / insertion_date / updated_at filter or ordering (details) |
GET /v3/{ledgerName}/accounts/{address} |
✅ | ✅ | Get an account |
GET /v3/{ledgerName}/accounts/{address}/balances |
❌ | ✅ | Get account balances |
GET /v3/{ledgerName}/accounts/{address}/volumes |
❌ | ✅ | Get account volumes |
GET /v3/{ledgerName}/volumes |
✅ | ✅ | Aggregate volumes (per-asset, generic account filter) |
GET /v3/{ledgerName}/logs |
✅ | ✅ | List per-ledger logs, ascending by ledger-local log id unless reverse=true (pagination). Ledger-scoped read → requires ledger:read (granular ledger:ReadLedger) on both transports |
GET /v3/{ledgerName}/stats |
✅ | ✅ | Ledger usage statistics (transaction, volume, reference, posting, log, revert, Numscript-execution, ephemeral-evicted and transient-used counts). A missing or deleted ledger returns 404 LEDGER_NOT_FOUND |
GET /v3/{ledgerName} |
✅ | ✅ | Get ledger info |
POST /v3/{ledgerName}/promote |
✅ | ❌ | Promote mirror ledger to normal mode |
GET /v3/ |
✅ | ✅ | List ledgers, paged by name (pagination) |
GET /v3/{ledgerName}/metadata-schema |
✅ | ❌ | Get declared metadata field types |
GET /v3/{ledgerName}/analyze-accounts |
✅ | ❌ | Analyze accounts and suggest Chart of Accounts |
GET /v3/{ledgerName}/analyze-transactions |
✅ | ❌ | Analyze transaction flow patterns |
PUT /v3/{ledgerName}/metadata-schema/{targetType}/{key} |
✅ | ❌ | Set metadata field type |
DELETE /v3/{ledgerName}/metadata-schema/{targetType}/{key} |
✅ | ❌ | Remove metadata field type |
POST /v3/{ledgerName}/prepared-queries |
✅ | ❌ | Create a prepared query |
PUT /v3/{ledgerName}/prepared-queries/{queryName} |
✅ | ❌ | Update a prepared query |
DELETE /v3/{ledgerName}/prepared-queries/{queryName} |
✅ | ❌ | Delete a prepared query |
GET /v3/{ledgerName}/prepared-queries |
✅ | ❌ | List prepared queries |
POST /v3/{ledgerName}/prepared-queries/{queryName}/execute |
✅ | ❌ | Execute a prepared query |
POST /v3/numscripts/analyze |
✅ | ❌ | Validate Numscript v1 source and return external inputs or typed diagnostics; requires ledger:ReadQuery when authentication is enabled (also granted by aggregate ledger:read) |
GET /v3/{ledgerName}/numscripts |
✅ | ❌ | List numscripts (greatest version of each), paged by name (pagination) |
GET /v3/{ledgerName}/numscripts/{name}?version= |
✅ | ❌ | Get numscript (version selector, empty/latest = greatest semver) |
GET /v3/{ledgerName}/numscripts/{name}/usage |
✅ | ❌ | Get invocation count + last-used timestamp. A missing or deleted ledger returns 404 LEDGER_NOT_FOUND; a never-invoked template returns a zero-valued 200 response |
GET /v3/{ledgerName}/numscripts/{name}/versions |
✅ | ❌ | List version history |
PUT /v3/{ledgerName}/numscripts/{name} |
✅ | ❌ | Save an immutable version (explicit full semver). Requires ledger:WriteLedger on both the dedicated route and gRPC Apply(SaveNumscript) |
GET /v3/{ledgerName}/account-types |
✅ | ❌ | List account types |
GET /v3/{ledgerName}/account-types/{typeName} |
✅ | ❌ | Get account type |
POST /v3/{ledgerName}/account-types |
✅ | ❌ | Add account type. Requires ledger:WriteMetadata on both the dedicated route and gRPC Apply |
DELETE /v3/{ledgerName}/account-types/{typeName} |
✅ | ❌ | Remove account type. Requires ledger:WriteMetadata on both the dedicated route and gRPC Apply |
PUT /v3/{ledgerName}/account-types/default-enforcement-mode |
✅ | ❌ | Set default enforcement mode (STRICT/AUDIT). Requires ledger:WriteMetadata on both the dedicated route and gRPC Apply |
GET /v3/_/logs/{sequence} |
✅ | ❌ | Fetch a single system log by bucket-wide sequence. No ledger identity → requires ledger ops-read (granular ledger:ReadOps) |
GET /v3/_/events-sinks |
✅ | ❌ | List configured event sinks with per-sink cursor and error status, including startup failures ({sinks, sinkStatuses}, parity with gRPC GetEventsSinks); cursor zero without error means delivery is pending, while a nonzero cursor can also reflect filtered or internal logs rather than external delivery |
POST /v3/_/events-sinks |
✅ | ❌ | Create an instance-wide sink from protobuf JSON; ledger:WriteOps, 201 {data: {name}}, optional Idempotency-Key |
DELETE /v3/_/events-sinks/{sinkName} |
✅ | ❌ | Remove an instance-wide sink; ledger:WriteOps, optional controllerId precondition and Idempotency-Key, 204 |
GET /v3/_/signing-keys |
✅ | ❌ | List registered Ed25519 signing keys, paged by key id (pagination) |
GET /v3/{ledgerName}/indexes |
✅ | ❌ | List indexes registered on a ledger |
GET /v3/{ledgerName}/indexes/{canonicalId} |
✅ | ❌ | Get a single Index registry entry |
GET /v3/{ledgerName}/indexes/{canonicalId}/status |
✅ | ❌ | IndexEntry (backfill cursor + per-replica IndexVersionState) |
GET /v3/{ledgerName}/indexes/{canonicalId}/inspect |
✅ | ❌ | Inspect metadata index (distinct values, facets, summary) — path unified with the DELETE/create routes |
POST /v3/{ledgerName}/indexes |
✅ | ❌ | Create an index (body: {"id": "<canonical>"}) |
DELETE /v3/{ledgerName}/indexes/{canonicalId} |
✅ | ❌ | Drop an index |
GET /v3/_/indexes?scope=all|bucket |
✅ | ❌ | List bucket-wide indexes (cluster-wide) |
GET /v3/_/indexes/status?ledger= |
✅ | ❌ | Aggregate indexer progress + IndexEntry list |
GET /v3/_/indexes/{canonicalId} |
✅ | ❌ | Get a single bucket-scoped Index entry |
GET /v3/_/indexes/{canonicalId}/status |
✅ | ❌ | Bucket-scoped IndexEntry |
POST /v3/{ledgerName}/bulk |
✅ | ❌ | Bulk operations (alternate path without underscore) |
GET /_info |
✅ | ❌ | Server build info (version, commit, buildDate, goVersion); unauthenticated, flat JSON (no data envelope) |
Read consistency selector. Every /v3 read accepts X-Consistency: stale over HTTP, matching the gRPC x-consistency: stale metadata. The
receiving node serves the read from its own store, with no ReadIndex barrier
and no forwarding. The default and linearizable keep the barrier. HTTP rejects
an unknown or repeated value with 400 INVALID_REQUEST; gRPC ignores unknown
values. The original ledger has no equivalent. See
HTTP API: Read consistency.
Every paged list uses the same page token on both transports
(pkg/pagecursor): base64url (unpadded) of {"key": <string>, "back": <bool>}.
{key: K} serves the rows strictly after K, {key: K, back: true} the page
that ends strictly before K, and {back: true} the last page; an empty token
serves the first page. Every page comes back in the requested order. The key
is the endpoint's position: decimal transaction id, ledger-local log id or
audit sequence; account address; ledger or numscript name; signing-key id;
decimal id or address for prepared queries (by target); base64url of the
encoded metadata value for index inspection. A malformed token, or a key that
is not a valid position for the endpoint, is InvalidArgument over gRPC and
400 INVALID_REQUEST over HTTP. The full format and the link rules are in the
PageToken schema of openapi.yml and in
the query pipeline.
| Surface | Request | Response |
|---|---|---|
HTTP lists: GET /v3/ (ledgers), /v3/{ledgerName}/accounts, /transactions, /logs, /numscripts, /v3/_/audit-entries, /v3/_/signing-keys |
pageSize (default 100, clamped to 1000), cursor, reverse=true |
{"data": [...], "next": "<token>", "previous": "<token>", "hasMore": <bool>}; next and previous omitted when there is no such page, hasMore set iff next is |
gRPC paged list RPCs: ListLedgers, ListAccounts, ListTransactions, ListLogs, ListNumscripts, ListAuditEntries, ListSigningKeys |
ListOptions.page_size, cursor, reverse |
x-next-cursor and x-previous-cursor trailers |
Prepared-query execution (LIST mode), both transports |
pageSize (zero/absent defaults to 100, clamped to 1000), cursor, reverse; a supplied HTTP pageSize query parameter overrides the body, including empty/zero |
cursor.pageSize reports the effective size; cursor.next, cursor.previous, cursor.hasMore |
Index inspection (distinctValues / facets), both transports |
pageSize, cursor |
nextCursor / previousCursor (gRPC next_cursor / previous_cursor), hasMore |
Prepared-query LIST limits apply before ID collection and enrichment, for both
HTTP and service gRPC. Input values retain the uint32 range: values above 1000
are clamped, while negative, fractional or overflowing inputs are invalid.
AGGREGATE_VOLUMES ignores page size and includes every matching account.
Prepared queries replace schema-defined v2 query templates, executed through
POST /v2/{ledger}/queries/{id}/run. Their explicit targets, execution modes and
cursor envelopes retain intentional v3 semantics. The LIST policy follows the
existing v3 paged-list default/cap rather than introducing a rejection limit.
A page token is relative to the requested order, so every page of a walk
repeats the same reverse and filter.
- Import/Export - Critical for migration and backups
- Ledger config update - Can be done manually via recreation
The POC uses a different architecture with Raft for replication:
- A single Raft group manages all ledgers and their transactions
- Write operations go through the leader
- Logs are stored via the Store (Pebble)
- A global log provides system-wide ordering and enables cross-ledger atomic operations
This architecture impacts certain implementation decisions:
- Bulk atomicity is handled at the Raft level - All actions in a bulk request are submitted as a single Raft command, enabling system-level atomicity
- Import must respect log sequence
- Export can be done from any node (local read)
See Global Log Architecture for details on how the two-level log architecture enables cross-ledger atomic operations.
The POC provides a gRPC API for internal service communication (Raft node forwarding to leader) and can be used by clients.
Cluster status, manual leadership transfer and primary/secondary store-metrics targets preserve unsigned 64-bit node identities in the existing protobuf fields, including per-peer enrichment and CLI arguments. The zero state-request target still routes to the leader; a zero transfer target rejects. Zero metrics targets select the local store. These v3 administration RPCs have no v2 ledger API equivalent and do not change HTTP routes or envelopes. See the cluster identity contract and old-reader limitations.
Ledger v3 requires a concrete 16-byte member identity on every membership
creation and discovery message. ClusterService.AddLearnerRequest.instance_id
is required and contains the raw bytes from the target's persisted
INSTANCE_ID marker. The inter-node JoinAsLearnerRequest.instance_id has the
same contract, and ClusterBootstrapService.GetPeers returns each member's 16-byte WAL identity
in PeerInfo.instance_id (distinct from the numeric PeerInfo.id node identifier). Missing or incorrectly sized registration identities
are rejected with gRPC InvalidArgument. Peer discovery aborts startup when a
returned member has an invalid identity, rather than retrying a malformed
response. This requirement predates v3.0.0-rc.1; no release contains an
identity-less membership format.
Paged list RPCs follow the shared ListOptions contract and publish the
x-next-cursor and x-previous-cursor trailers; see Pagination.
| Method | Description | Status |
|---|---|---|
CreateLedger |
Create a new ledger | ✅ |
DeleteLedger |
Delete a ledger | ✅ |
ListLedgers |
Stream ledgers, paged by name | ✅ |
GetLedger |
Get ledger by name or ID | ✅ |
GetTransaction |
Get transaction by ID | ✅ |
ListTransactions |
Stream transactions for a ledger | ✅ |
ListAccounts |
Stream accounts for a ledger | ✅ |
GetAccount |
Get account by address | ✅ |
GetPrimaryMetrics |
Get primary Pebble store metrics | ✅ |
GetSecondaryMetrics |
Get secondary (read index) Pebble store metrics | ✅ |
CheckStore |
Verify store integrity (hash chain + derived data) | ✅ |
GetEventsSinks |
Get per-sink configurations and statuses | ✅ |
GetMetadataSchemaStatus |
Get the declared metadata schema for a ledger | ✅ |
AnalyzeTransactions |
Discover transaction flow patterns | ✅ |
Apply(CreatePreparedQuery) |
Create a named prepared query | ✅ |
Apply(UpdatePreparedQuery) |
Update an existing prepared query | ✅ |
Apply(DeletePreparedQuery) |
Remove a prepared query | ✅ |
ListPreparedQueries |
List all prepared queries for a ledger | ✅ |
ExecutePreparedQuery |
Execute a prepared query against the read index | ✅ |
Barrier |
No-op Raft proposal to ensure all prior writes are applied | ✅ |
Apply |
Apply a ledger action (write operations). Authorization is per request in the batch: each variant requires the same granular scope as its dedicated HTTP route or gRPC RPC — Apply is never a lower bar than the single-shot path. Unknown or malformed variants fail closed on ledger:WriteOps. See Apply request scopes |
✅ |
Apply(CreateLedger) with mirror mode |
Create a mirror ledger | ✅ |
Apply(PromoteLedger) |
Promote mirror ledger to normal mode | ✅ |
Apply(SaveNumscript) |
Save an immutable numscript version (explicit full semver). Requires ledger:WriteLedger, matching PUT /v3/{ledgerName}/numscripts/{name} |
✅ |
GetNumscript |
Get a numscript by name and version selector | ✅ |
AnalyzeNumscript |
Validate v1 source and return external inputs and typed diagnostics without ledger state access; HTTP parity is POST /v3/numscripts/analyze |
✅ |
ListNumscripts |
List the greatest version of each saved numscript | ✅ |
ListNumscriptVersions |
List the latest pointer and every stored version | ✅ |
ListAuditEntries |
Stream audit log entries (success + failure). Request is { options } only — no dedicated filter fields. Follows the shared ListOptions contract: cursor/page_size/reverse/checkpoint_id plus a bare-audit-field QueryFilter (outcome, ledger, caller_subject, order_type, idempotency_key, seq, proposal_id, timestamp, log_seq — bare fields resolved against the audit query target, EN-1549 replacing the old audit[...] syntax) resolved through the audit secondary index. idempotency_key supports equality and prefix matching and may return several historical entries after key reuse. Ledger scope and outcome selection are expressed as filter conditions |
✅ |
GetAuditEntry |
Get a single audit entry by sequence number | ✅ |
ListLogs |
Stream system logs for a ledger (requires ledger field). Follows the shared ListOptions contract: cursor/page_size/reverse (descending ledger-local id)/checkpoint_id plus log_id and date filters. Ledger-scoped read → requires ledger:read (granular ledger:ReadLedger), same as the HTTP GET /v3/{ledgerName}/logs route |
✅ |
GetLog |
Get a single system log by bucket-wide sequence number. No ledger identity in the request → requires ledger ops-read (granular ledger:ReadOps), like the HTTP GET /v3/_/logs/{sequence} route |
✅ |
ListSigningKeys |
Stream registered signing keys, paged by key id | ✅ |
Discovery |
Return server capabilities (response signing config), build info (ServerInfo: version, commit, build date, Go version), and committed ClusterPolicy |
✅ |
AnalyzeAccounts |
Analyze accounts and suggest Chart of Accounts | ✅ |
GetIndexStatus |
Read index builder progress (lag, file size) | ✅ |
GetLedgerStats |
Get aggregate usage statistics (transaction, volume, reference, posting, log, revert, Numscript-execution, ephemeral-evicted and transient-used counts) | ✅ |
AggregateVolumes |
Per-asset aggregated volumes for filtered accounts | ✅ |
InspectIndex |
Inspect metadata index (distinct values, facets, summary) | ✅ |
The Apply method is the single entry point for all ledger write operations. It provides a unified way to apply any ledger action through a single gRPC call.
Benefits:
- Simplified API surface - single method for all write operations
- Consistent behavior across all action types
- Better for bulk operations executed in parallel
- Simplified client logic
- Efficient forwarding between Raft nodes
Request: ApplyRequest containing a LedgerAction with:
ledger_id: Target ledger IDidempotency_key: Optional idempotency key (on the enclosingApplyBatch— one per atomic batch)skip_response: Whentrue, strips log payloads from the response (onlysequenceis returned per log). Useful for historical ingestion where the client does not need the full response on success.- One of:
create_transaction: Create a new transactionadd_metadata: Add metadata to an account or transactionrevert_transaction: Revert a transactiondelete_metadata: Delete metadata from an account or transactionsave_numscript: Save an immutable numscript version (explicit full semver)
Response: common.Log - The log entry created by the action (stripped to sequence only when skip_response is set)
Note: Individual RPC methods like CreateTransaction, RevertTransaction, SaveAccountMetadata, etc. have been consolidated into the Apply method for a cleaner API.
Apply accepts a batch, and authorization is evaluated per request in that batch (internal/adapter/grpc/server_bucket.go:141-162). Each variant resolves to a single granular scope through auth.RequiredScopeForRequest (internal/adapter/auth/request_scope.go).
The governing rule: a batched operation requires the same scope as its dedicated HTTP route or gRPC RPC. Apply is never a lower bar than the single-shot path — otherwise it would be a way around the dedicated route's gate.
| Request variant | Scope | Matches |
|---|---|---|
apply (create_transaction, revert_transaction) |
ledger:WriteTransaction |
POST /v3/{ledgerName}/transactions |
apply (add_metadata, delete_metadata) |
ledger:WriteMetadata |
metadata routes |
apply (add_account_type, remove_account_type, set_default_enforcement_mode) |
ledger:WriteMetadata |
/v3/{ledgerName}/account-types* |
create_ledger, delete_ledger, promote_ledger, create_index, drop_index, save_numscript |
ledger:WriteLedger |
the requireLedgersWrite route group |
save_ledger_metadata, delete_ledger_metadata, add_account_type, remove_account_type, set_default_enforcement_mode, set_metadata_field_type, remove_metadata_field_type |
ledger:WriteMetadata |
the requireMetadataWrite route group |
create_prepared_query, update_prepared_query, delete_prepared_query |
ledger:WriteQuery |
the requireQueriesWrite route group |
create_query_checkpoint, delete_query_checkpoint, set_query_checkpoint_schedule, delete_query_checkpoint_schedule |
ledger:WriteCluster |
none — BucketService.Apply only |
| events sinks | ledger:WriteOps |
POST /v3/_/events-sinks, DELETE /v3/_/events-sinks/{sinkName} |
| signing keys, maintenance mode | ledger:WriteOps |
operator surface, no dedicated business route |
| unknown / malformed / unset variant | ledger:WriteOps |
fail-closed default |
Two properties are enforced by tests rather than convention:
- Exhaustiveness.
request_scope_exhaustiveness_test.gowalks theRequest.typeandLedgerAction.dataoneof descriptors and fails when a variant has no explicit scope decision. A new proto variant cannot ship on an accidental default — CI blocks it until someone classifies it. - Fail-closed is only for the unknown. The classifier reports whether a decision was explicit, so "nobody decided" is distinguishable from a deliberate
ledger:WriteOps.
Breaking change (EN-1506). The four
*_query_checkpoint*variants previously resolved toledger:WriteOps. BecauseDefaultMappinggrantsledger:WriteOpsto anyledger:writetoken butledger:WriteClusteronly toledger:admin, a non-admin caller could create or delete query checkpoints throughApply— an operation the dedicatedClusterServiceRPCs restrict to admins. They now requireledger:WriteCluster. Callers driving query checkpoints throughApplywith aledger:writetoken must move toledger:adminor add the granularledger:WriteClusterscope.
Breaking change (EN-1954).
BucketService.CreatePreparedQuery,UpdatePreparedQuery,DeletePreparedQueryandClusterService.CreateQueryCheckpoint,DeleteQueryCheckpointare removed. Every one of these operations is aledger.Requestvariant submitted throughBucketService.Apply, so it is now batchable, idempotent under a batch key, signable as part of the batch, scope-checked once, and forwarded to the leader through the single Apply path. Required scopes and error semantics are unchanged. A newly executed checkpoint creation waits for local read-index readiness (leader only withskip_response), unless concurrent deletion supersedes it. An idempotent replay returns the historical logs immediately, including after deletion or while the original creation still materializes; it does not guarantee current existence or readiness. Server-produced response provenance preserves this distinction across follower forwarding. The HTTP prepared-query routes are unchanged — they already delegated to the same Apply backend.
Note that POST /v3/{ledgerName}/bulk shares this classifier but can only express the four LedgerAction variants its JSON decoder accepts (CREATE_TRANSACTION, ADD_METADATA, REVERT_TRANSACTION, DELETE_METADATA), so the table's top-level rows are unreachable over HTTP bulk.
Maintenance mode is a v3 cluster operation without a corresponding v2 service
gRPC contract. Its rejection remains UNAVAILABLE with Ledger reason
MAINTENANCE_MODE; the server adds grpc-retry-pushback-ms: -1 to stop native
gRPC retries. Local and forwarded statuses preserve their message and details,
and ledgerctl renders a clear cluster-maintenance message. HTTP remains
503 MAINTENANCE_MODE; routes, JSON envelopes, and OpenAPI are unchanged.
Explicit application retries and keyed recovery after an uncertain commit
retain their own policy. See the gRPC error contract
for rollout and compatibility details.
Business errors from the processing layer are mapped to gRPC status codes with structured ErrorInfo details via the Describable contract in internal/domain. This allows clients to programmatically identify error types without parsing error messages. See internal/domain/errors.go for the canonical list — the Reason() method on each typed error returns the constant below, while FailureDetails() supplies the typed variant that determines the code and public structured context for serializable errors. domain.PublicErrorDetails renders the public message and facts without exposing diagnostic Error() text; the keys listed below describe the public response.
Each error response includes a google.rpc.ErrorInfo detail with:
reason: Machine-readable error reason constant (e.g.,LEDGER_ALREADY_EXISTS)domain: Always"ledger"metadata: Error-specific key-value pairs with context
Adding a new error: define a typed error implementing domain.Describable (Kind, Reason). If the FSM can emit it, also implement domain.SerializableError. A definitive business rejection supplies typed FailureDetails() for audit and idempotency; a technical failure implements UncommittedFailure, returns no audited details, and exits before both writers. Keep diagnostic text in Error(); public presentation must not expose it. The exhaustive kindToGRPCCode switch + reflection test enforce that no new error reaches the API without a mapping. Keep this table in sync as a reference.
| Error | gRPC Code | Reason | Metadata |
|---|---|---|---|
| Ledger already exists | ALREADY_EXISTS |
LEDGER_ALREADY_EXISTS |
name |
| Ledger not found | NOT_FOUND |
LEDGER_NOT_FOUND |
name |
| Ledger deleted | FAILED_PRECONDITION |
LEDGER_DELETED |
name |
| Ledger in mirror mode | FAILED_PRECONDITION |
LEDGER_IN_MIRROR_MODE |
name |
| Ledger not in mirror mode | FAILED_PRECONDITION |
LEDGER_NOT_IN_MIRROR_MODE |
name |
| Idempotency key conflict | ALREADY_EXISTS |
IDEMPOTENCY_KEY_CONFLICT |
key |
| Idempotency check failed | INTERNAL |
IDEMPOTENCY_CHECK_FAILED |
(none) |
| Transaction reference conflict | ALREADY_EXISTS |
TRANSACTION_REFERENCE_CONFLICT |
ledger, reference |
| Transaction not found | NOT_FOUND |
TRANSACTION_NOT_FOUND |
transactionId |
| Transaction already reverted | FAILED_PRECONDITION |
TRANSACTION_ALREADY_REVERTED |
transactionId |
| Revert target created in the same batch | INVALID_ARGUMENT |
REVERT_TARGET_CREATED_IN_BATCH |
transactionId |
| Transaction state inconsistent | INTERNAL |
TRANSACTION_STATE_INCONSISTENT |
transactionId, operation |
| Insufficient funds | FAILED_PRECONDITION |
INSUFFICIENT_FUNDS |
account, asset, amount, balance |
| Volume overflow | FAILED_PRECONDITION |
VOLUME_OVERFLOW |
account, asset, side, amount, current |
| Volume not materialized | INTERNAL |
VOLUME_NOT_MATERIALIZED |
account, asset, side |
| Balance not found | FAILED_PRECONDITION |
BALANCE_NOT_FOUND |
account, asset |
| Balance not preloaded | UNAVAILABLE |
BALANCE_NOT_PRELOADED |
account, asset, color (public context; no audited facts) |
| Numscript parse error | INVALID_ARGUMENT |
NUMSCRIPT_PARSE_ERROR |
(none; diagnostic text stays server-side) |
| Numscript compile error | INVALID_ARGUMENT |
NUMSCRIPT_COMPILE_ERROR |
Code-specific stable facts, such as expected and got for a type mismatch |
| Numscript execution error | FAILED_PRECONDITION |
NUMSCRIPT_EXECUTION_ERROR |
Code-specific stable facts, such as asset, needed and available for missing funds |
| Numscript runtime error | INTERNAL |
NUMSCRIPT_RUNTIME |
(none; technical failure) |
| Numscript not found | NOT_FOUND |
NUMSCRIPT_NOT_FOUND |
name |
| Numscript invalid version | INVALID_ARGUMENT |
NUMSCRIPT_INVALID_VERSION |
version |
| Numscript version already exists | ALREADY_EXISTS |
NUMSCRIPT_VERSION_ALREADY_EXISTS |
name, version |
| Validation error (generic) | INVALID_ARGUMENT |
VALIDATION |
(none — see message) |
| Audit disabled | FAILED_PRECONDITION |
AUDIT_DISABLED |
(none) |
| Maintenance mode | UNAVAILABLE |
MAINTENANCE_MODE |
(none) |
| Stale proposal | UNAVAILABLE |
STALE_PROPOSAL |
(none) |
| Stale inputs resolution (Numscript inputs, or a revert's target observation) | UNAVAILABLE |
STALE_INPUTS_RESOLUTION |
(none) |
| Preload unavailable (discovery failed; forwarded for idempotent replay) | UNAVAILABLE |
PRELOAD_UNAVAILABLE |
(none) |
| Raft node already absent during removal | NOT_FOUND |
RAFT_NODE_NOT_IN_CLUSTER |
(none) |
| Raft node removal committed; durable FSM apply still pending | UNAVAILABLE |
RAFT_NODE_REMOVAL_COMMITTED |
nodeId, committedIndex |
| Writes blocked — disk full | RESOURCE_EXHAUSTED |
WRITES_BLOCKED_DISK_FULL |
(none) |
| Authoritative sequence exhausted | RESOURCE_EXHAUSTED |
SEQUENCE_EXHAUSTED |
counter (transactionId, ledgerId, ledgerLogId, logSequence, auditSequence, or mirrorV2LogId) |
| Writes blocked — clock skew | UNAVAILABLE |
WRITES_BLOCKED_CLOCK_SKEW |
(none) |
| Metadata limit exceeded | INVALID_ARGUMENT |
METADATA_LIMIT_EXCEEDED |
dimension, limit, actual |
| Metadata not found | NOT_FOUND |
METADATA_NOT_FOUND |
target, key |
| Metadata field not in schema | FAILED_PRECONDITION |
METADATA_FIELD_NOT_IN_SCHEMA |
target, key |
| Invalid cron expression | INVALID_ARGUMENT |
INVALID_CRON_EXPRESSION |
expression, details |
| Prepared query already exists | ALREADY_EXISTS |
PREPARED_QUERY_ALREADY_EXISTS |
ledger, name |
| Prepared query not found | NOT_FOUND |
PREPARED_QUERY_NOT_FOUND |
ledger, name |
| Filter compilation error | INVALID_ARGUMENT |
FILTER_COMPILATION_ERROR |
(none; diagnostic text stays server-side) |
| Index not found | FAILED_PRECONDITION |
INDEX_NOT_FOUND |
index |
| Index building | UNAVAILABLE |
INDEX_BUILDING |
(none; read-side readiness) |
| Index inconsistent | INTERNAL |
INDEX_INCONSISTENT |
(none — internal index/storage details remain server-side) |
| Account not matching type | FAILED_PRECONDITION |
ACCOUNT_NOT_MATCHING_TYPE |
address |
| Account type not found | NOT_FOUND |
ACCOUNT_TYPE_NOT_FOUND |
name |
| Account type already exists | ALREADY_EXISTS |
ACCOUNT_TYPE_ALREADY_EXISTS |
name |
| Account type conflict | FAILED_PRECONDITION |
ACCOUNT_TYPE_CONFLICT |
pattern, existingName, existingPattern |
| Account type has accounts | FAILED_PRECONDITION |
ACCOUNT_TYPE_HAS_ACCOUNTS |
name |
| Invalid pattern | INVALID_ARGUMENT |
INVALID_PATTERN |
pattern, details |
| Transient account non-zero | FAILED_PRECONDITION |
TRANSIENT_ACCOUNT_NON_ZERO |
account, asset |
| Sink already exists | ALREADY_EXISTS |
SINK_ALREADY_EXISTS |
name |
| Sink not found | NOT_FOUND |
SINK_NOT_FOUND |
name |
| Sink batch size too large | INVALID_ARGUMENT |
SINK_BATCH_SIZE_TOO_LARGE |
name, batchSize, max |
| Invalid order type (protocol mismatch) | INTERNAL |
INVALID_ORDER_TYPE |
typeName |
| Invalid apply type (protocol mismatch) | INTERNAL |
INVALID_APPLY_TYPE |
typeName |
| Storage operation failed | INTERNAL |
STORAGE_OPERATION_FAILED |
(none; diagnostic operation remains server-side) |
| Preload coverage miss (admission contract violation) | INTERNAL |
COVERAGE_MISS |
(none — coverage context remains in diagnostics, outside audit) |
| Checkpoint ID required | INVALID_ARGUMENT |
CHECKPOINT_ID_REQUIRED |
(none) |
The REST adapter uses the same Describable.Reason() as the JSON errorCode field — wire contract is uniform with gRPC's ErrorInfo.reason. HTTP status code is derived from Kind via kindToHTTPStatus:
| ErrorKind | HTTP Status |
|---|---|
KindValidation |
400 Bad Request |
KindNotFound |
404 Not Found |
KindAlreadyExists |
409 Conflict |
KindConflict |
409 Conflict |
KindPrecondition |
400 Bad Request |
KindUnavailable |
503 Service Unavailable |
KindResourceExhausted |
429 Too Many Requests |
KindUnauthenticated |
401 Unauthorized |
KindPermissionDenied |
403 Forbidden |
KindInternal |
500 Internal Server Error |
Every response the table maps to 503 also carries Retry-After: 1 — the kind is by definition retry-now, so the header covers the whole class, not just the no-leader sentinel.
Classified failures with no reason (EN-2081). A domain.Classifiable declares a Kind and nothing else, so it has no Reason() to publish. The status code comes from the same table, but the response carries a coarse kind-level errorCode — INVALID_REQUEST, UNAVAILABLE, CONFLICT, … — and the gRPC surface sends no ErrorInfo at all. A bulk element answers the same status and message with the generic element errorCode ERROR.
This is the tier for failures that name no business outcome a client could branch on:
| Failure | Previously | Now |
|---|---|---|
AGGREGATE_VOLUMES on a non-ACCOUNTS prepared query |
UNKNOWN / 500 (bare errors.New) |
INVALID_ARGUMENT / 400, no ErrorInfo |
Unsupported QueryMode |
UNKNOWN / 500 (bare errors.New) |
INVALID_ARGUMENT / 400, no ErrorInfo |
Empty envelope list (gRPC Apply guard) |
INVALID_ARGUMENT + VALIDATION |
INVALID_ARGUMENT, no ErrorInfo |
| Idempotency key too long / invalid UTF-8 | INVALID_ARGUMENT + VALIDATION |
INVALID_ARGUMENT, no ErrorInfo |
| Checkpoint trigger not last in a bulk request | INVALID_ARGUMENT + VALIDATION |
INVALID_ARGUMENT, no ErrorInfo |
The last three previously carried the generic VALIDATION reason, which told a client nothing its status code did not already say while committing the server to an identifier it could never rename. Domain validation sentinels are unaffected and keep VALIDATION: they are FSM-reachable, and the audit chain persists a reason.
Clients must treat the HTTP status as the primary signal and errorCode as a refinement; a coarse code is not a reason and is not a stable identifier to pattern-match on.
Bare gRPC statuses at the HTTP boundary. A handler can surface a google.golang.org/grpc/status error that is not a domain Describable; handleError translates exactly two codes and deliberately lets every other one fall through to the sanitized 500:
| gRPC code | HTTP response |
|---|---|
INVALID_ARGUMENT |
400, errorCode: INVALID_REQUEST |
UNAVAILABLE |
503 + Retry-After: 1, errorCode: UNAVAILABLE |
The UNAVAILABLE row also covers forwarding interruptions. For streams,
normalizeStreamEnd re-codes raw peer cancellation while the caller is live.
For unary calls, grpcerr.Conn.Invoke only re-codes the exact bare
ErrClientConnClosing when the actual local connection is shut down and the
caller is live. The wrapped connection must expose the state of the connection
used for Invoke through GetState(); the production call site uses a raw
*grpc.ClientConn.
Server-authored statuses on a live connection and structured failures retain
their existing handling; an identical bare close status received just before
local shutdown is indistinguishable at the post-invocation state check and is
also re-coded. Both surfaces expose a retryable
transport error, but it does not prove that a write failed to commit: retry
with the original idempotency key and payload. See the
gRPC error contract.
Breaking change in #432: HTTP errorCode JSON field previously used HTTP-specific codes ("CONFLICT", "NOT_FOUND", "SCRIPT_PARSE_ERROR", "INSUFFICIENT_FUNDS", ...) that were sometimes the same as the gRPC Reason and sometimes different. After the Describable refactor (#432) it is uniformly Reason() from the table above — e.g. "LEDGER_ALREADY_EXISTS" (was "CONFLICT"), "NUMSCRIPT_PARSE_ERROR" (was "SCRIPT_PARSE_ERROR"). Update REST clients to widen pattern matching accordingly.
Reason is the reliable discriminator; Kind is only as good as the client's reason table. The server-side Kind enum has two values (KindConflict and KindPrecondition) that both serialise to codes.FailedPrecondition on the wire, so the status code alone cannot separate them. Ledger's own reconstruction (internal/adapter/grpcerr, used by both ledgerctl and the follower-to-leader forwarding path) resolves this by deriving the kind from the reason first — KindForReason(ReasonCode(reason)) — and falling back to the status code only for a reason the build's ErrorReason enum does not know. A KindConflict response therefore reads back as KindConflict, provided the client recognises the reason.
That proviso is the catch for third-party SDKs: a client older than the server, or one that derives the kind from the status code alone, still reports KindPrecondition for every FailedPrecondition response and will misclassify a conflict (e.g. ledger deleted, transaction already reverted). Match on Reason() (LEDGER_DELETED, TRANSACTION_ALREADY_REVERTED, etc.) rather than on the reconstructed Kind() — the reason is preserved end-to-end and never depends on the client's version.
The normalized boundary contract. Inside ledger, every business-facing surface — the HTTP error handler, the bulk per-element mapper and ledgerctl — reads a failure through one contract, internal/adapter/apierr. apierr.Describe(err) returns a Descriptor (semantic Kind, stable Reason, client-safe Message, structured Facts) and hides which of two provenances the failure had: raised locally, where the kind is domain.Kind(d) and therefore a pure function of the reason; or decoded from a peer, where the kind is the one the wire carried. There is no second mapping table per surface — HTTP maps Kind to a status through kindToHTTPStatus, gRPC through grpcerr.CodeForKind, and the decoder reverses that same table. apierr imports internal/domain and nothing else, so HTTP is not coupled to any gRPC detail.
The decoded representation lives at the adapter boundary by design, not in internal/domain. A decoded failure carries a reason and message belonging to the sending build. The audit chain hashes the stable reason, code and facts from FailureDetails(), never the diagnostic Error() text. No package under internal/domain, internal/application/admission, internal/infra/state, internal/infra/plan or internal/infra/preload may import apierr; scripts/check-repo-invariants enforces that as a dependency rule.
Two axes, deliberately separate. The semantic ErrorKind answers the client; the gRPC status code is a transport-behaviour signal that drives retry and hop behaviour. The receiver preserves both: the kind through the Descriptor, the exact original status through the decoder's carrier (GRPCStatus()). Do not reconstruct a status from a Kind; it is lossy in both directions — CodeForKind sends both KindConflict and KindPrecondition as codes.FailedPrecondition, so a code cannot name a kind, and a reason from a newer build carries a kind this enum cannot derive at all. The axes may also disagree deliberately: the removed READ_INDEX_NOT_CAUGHT_UP was semantically KindUnavailable (503 + Retry-After on REST) while travelling as codes.FailedPrecondition, because actions.GRPCRetryPolicy makes five total attempts on codes.Unavailable with four exponential backoffs totaling 3.75s before jitter; the semantic code would have turned a read-index lag into a multi-second client-side delay.
Mismatch policy. A reason ledger knows is a reason whose legitimate wire codes it knows: CodeForKind(KindForReason(reason)), today exactly one code, since every enum reason is encoded through describableToGRPCStatus and nothing else. Validation stays reason-keyed rather than kind-keyed so a reason that must travel under a second code can be widened alone. A known reason arriving under a code outside that set is a protocol fault, not a business outcome: every consumer branches on apierr.InvalidWire and answers its own internal-error representation — 500 INTERNAL_ERROR + correlation ID on REST (unitary and per bulk element), codes.Unknown + correlation ID on gRPC, the invalid-pair message on ledgerctl — so the received message and metadata are dropped rather than echoed as trusted business information. An unknown reason cannot be validated and is preserved verbatim — reason, message, metadata and exact status.
On internal forwarding hops, grpcerr.OriginalStatus preserves decoded statuses
through outer error wrappers and before cursor cancellation normalization. The
original code, public message, and every status detail survive the hop; raw
transport cancellation still follows the caller-context policy. Regression
tests cover both production cursor types, wrapped repeated hops, exact REST/bulk
message parity, and complete gRPC status parity between leader and followers.
Unitary and bulk HTTP responses use the descriptor message for recognized
public errors, omitting outer routing or Raft prefixes on both local and
forwarded paths; internal-error sanitization remains in force.
Client-side usage (Go):
import (
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/status"
)
st, ok := status.FromError(err)
if ok {
for _, detail := range st.Details() {
if info, ok := detail.(*errdetails.ErrorInfo); ok && info.Domain == "ledger" {
switch info.Reason {
case "INSUFFICIENT_FUNDS":
// Handle insufficient funds using info.Metadata
case "LEDGER_NOT_FOUND":
// Handle ledger not found
}
}
}
}Admission rejects the exact names _ (system API routes) and _system
(system events) on every ledger-scoped write, before proposing to Raft.
This applies equally to HTTP and gRPC. Other underscore-prefixed names,
including _systemx and _System, remain valid.
Native cluster-policy controls introduced at the RC.3 to RC.4 cutover no longer appear in business audit listings. Historical policy audit entries remain readable. Audit response fields and service revision 40 are unchanged. Discovery still returns the committed policy.