Skip to content

Latest commit

 

History

History
1846 lines (1541 loc) · 123 KB

api-comparison.md

File metadata and controls

1846 lines (1541 loc) · 123 KB

API Comparison: ledger vs github.com/formancehq/ledger

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 /v3 prefix (e.g. POST /v3/{ledgerName}/transactions). Ops endpoints (/health, /livez, /readyz, /clusterz, /_info, /debug/pprof) are intentionally unversioned. The original ledger's /v2 is 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.

HTTP transaction request signatures

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 metadata address validation

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.

Idempotency after metadata policy tightening

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.

Ledger integrity checking

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.

Summary

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.

LedgerInfo reads (EN-2780 / EN-1635)

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.

Prepared-query filter shape validation

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.

Superuser authentication

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.

Authenticated caller attribution

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.

Service protocol compatibility (EN-1851)

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.

Authentication and resource exhaustion (EN-2783)

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 comparison

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 | ⚠️ Partially/Not implemented | ❌ Absent


Ledger-log JSON contract

Large-integer response encoding

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.


Features Implemented in POC

1. Transaction Creation

Endpoint: POST /v3/{ledgerName}/transactions

Features:

  • ✅ Creation with direct postings
  • ✅ Creation with Numscript script
  • ✅ Numscript variables support
  • ✅ Balance verification (insufficient funds)
  • ✅ force option (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: string and 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)
  • ✅ oneof selector (conditional routing)
  • ✅ overdraft() function (dynamic overdraft calculation)

Color of money semantics (new in this POC):

  • Posting.color is 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 on ledgerctl transactions create.
  • Account.volumes is a deterministic sorted list of {asset, color, volumes} entries. The HTTP query parameter ?collapseColors=true on GET /{ledger}/accounts/{address} sums every colored bucket of the same asset into a single entry under color: "".
  • AggregatedVolume.color is set on every entry returned by GET /{ledger}/volumes. The same ?collapseColors=true flag 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 a Posting with Color = "RED" and only draws from the matching bucket. Spending more than the bucket holds returns ErrInsufficientFunds even when other colored or uncolored buckets have plenty.

See Numscript Guide for complete documentation.

2. Transaction Revert

Endpoint: POST /v3/{ledgerName}/transactions/{transactionId}/revert

Features:

  • ✅ Standard revert
  • ✅ force option (ignore insufficient balances)
  • ✅ atEffectiveDate option (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) and revertedAt (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).

A compensating transaction can itself be reverted

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.

3. Metadata Management

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 metadata
  • DELETE /v3/{ledgerName}/accounts/{address}/metadata/{key} - Delete account metadata
  • POST /v3/{ledgerName}/transactions/{transactionId}/metadata - Save transaction metadata
  • DELETE /v3/{ledgerName}/transactions/{transactionId}/metadata/{key} - Delete transaction metadata
  • GET /v3/{ledgerName}/metadata-schema - Get metadata schema (per-field declared type)
  • PUT /v3/{ledgerName}/metadata-schema/{targetType}/{key} - Set/change metadata field type
  • DELETE /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.

4. Bulk Operations

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-Key header - 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.

5. Ledger Management

Endpoints:

  • POST /v3/{ledgerName} - Create a ledger. Optional body fields: metadata, mode, mirrorSource, defaultEnforcementMode, initialSchema (metadata field types), and accountTypes (full account-type model — name/pattern/persistence/segmentTypes). These mirror the gRPC CreateLedgerRequest. Initial typed metadata is atomic with creation in normal and mirror modes and requires only ledger:WriteLedger; later saves require ledger:WriteMetadata. JSON null entries are omitted. Schema declarations do not enforce write-time value types. See the creation contract.
  • DELETE /v3/{ledgerName} - Delete a ledger
  • GET /v3/{ledgerName} - Get ledger info (read)
  • GET /v3/ - List all ledgers (read)

5b. Account Types

Endpoints:

  • GET /v3/{ledgerName}/account-types - List all account types for a ledger
  • GET /v3/{ledgerName}/account-types/{typeName} - Get details of a specific account type
  • POST /v3/{ledgerName}/account-types - Add a new account type (body: name, pattern, optional persistence and segmentTypes)
  • 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
  • ✅ world account 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)

6. Transaction Read

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>

8. Mirror Ledgers

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 endpoint GET /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 operator Ledger CRD (mirrorSource.postgres.awsIamAuth.region + optional assumeRoleArn for cross-account / multi-tenant mirrors) — see misc/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) or FOLLOWING (up to date)
  • cursor: Last ingested v2 log ID
  • sourceLogCount: Latest known log ID in the v2 source
  • remainingLogs: 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 metadata
  • SET_METADATA — Sets metadata on an account or transaction
  • REVERTED_TRANSACTION — Replays a revert
  • DELETE_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 mode changes from MIRROR to NORMAL
  • The mirrorSource configuration 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.

9. Numscript Library

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 template
  • GET /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 code
  • version (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 name
  • content (string): Numscript source code
  • version (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. 0 means 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.

Filter input formats (dual-format contract, EN-1511)

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.

10. Prepared Queries and User-Configurable Indexes

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 — Create
  • PUT /v3/{ledgerName}/prepared-queries/{name} — Update filter
  • DELETE /v3/{ledgerName}/prepared-queries/{name} — Delete
  • GET /v3/{ledgerName}/prepared-queries — List
  • POST /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 reverted and reverted_at; v3 matches this: RevertedCondition for the boolean and BuiltinUintCondition(REVERTED_AT) (behind the reverted-at index) for the date range. The v3-only navigable ids revertedByTransactionId / revertsTransactionId are 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)

Missing Features

1. ❌ Log Import

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

2. ❌ Log Export

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

3. ✅ Unique Reference Validation

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

4. ✅ Ledger Metadata Update

Description: Ability to add/modify metadata on a ledger after creation.

Current status: Fully implemented.

  • POST /v3/{ledgerName}/metadata - Add/modify metadata
  • DELETE /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.

5. ❌ Ledger Configuration Update

Description: Modify certain ledger parameters after creation (e.g., maintenanceInterval).

To implement:

  • PATCH /v3/{ledgerName} or PUT /v3/{ledgerName}/config

6. ❌ Account date fields (first_usage, insertion_date, updated_at)

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 Account schema is address / metadata / volumes only (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: false for QUERY_TARGET_ACCOUNTS (internal/proto/commonpb/common_queryfilter_validity.pb.go), so no builtin date condition is valid on the accounts target. ConditionKindField is true there, 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_usage falls through decodeMatch to parseMetadataKey and 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_date is stamped once, on insert, and never lowered — on release/v2.4 only the insert arm sets it, to the incoming value or to transaction_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_at moves only when the account row is actually rewritten. The update arm runs only when the incoming row carries an earlier first_usage than 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_usage to the incoming value, or to the database's transaction_date() when the write supplies none. A transaction supplies its effective date; a plain SaveAccountMetadata supplies nothing and takes transaction_date(), so a metadata-only account does have a first_usage with 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.


Intentionally Removed Features

1. Post-Commit Volumes (Always Present) / Pre-Commit Volumes (Removed)

Description: In the original ledger, transaction creation responses include volumes before and after the commit:

  • postCommitVolumes - Volumes after transaction application
  • preCommitVolumes - Volumes before transaction application
  • postCommitEffectiveVolumes - Effective volumes after application (with effective timestamp)
  • preCommitEffectiveVolumes - Effective volumes before application (with effective timestamp)

POC Status:

  • postCommitVolumes is part of every persisted transaction. It rides on the transaction object (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, and preCommitEffectiveVolumes remain 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 expandVolumes flag is removed).
  • Each account maps to a flat list of (asset, color) rows; the empty color is the uncolored bucket.
  • preCommitVolumes and effective volumes remain removed; use dedicated read endpoints if needed.

Behavior Differences to Note

1. Negative Balance Handling

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)

2. "world" Account

POC: The "world" account has infinite funds (universal source).

Original: Same behavior.

Status: ✅ Compliant

3. Posting Order in Revert

POC: Postings are reversed (source ↔ destination) AND the order is reversed.

Original: Same behavior.

Status: ✅ Compliant

4. Idempotency

POC:

  • Supported via Idempotency-Key header (HTTP) or ApplyBatch.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.

5. Index readiness and metadata schema retypes (EN-1323)

POC: Index "readiness" is a per-replica concept driven by the local IndexVersionState (current_version, pending_version), not by a cluster-wide flag.

  • CreateIndex is strict: an existing (ledger, canonical IndexID) fails with INDEX_ALREADY_EXISTS (HTTP 409, gRPC AlreadyExists), including while building or retyping. The registry and local build state remain unchanged. Retained batch-idempotency replay keeps returning its original result.
  • CreateIndex registers a new index at forward_encoding_version = 1 and each replica starts a local backfill. When the backfill catches up to the global indexer cursor, the replica performs a local atomic switch (current_version 0 → 1) in a single Pebble batch. There is no cluster-wide IndexReady proposal — 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 GetIndexStatus and wait for current_version > 0 with pending_version == 0; a RETYPE is complete once current_version has advanced past its pre-retype value (per-replica numbers are allocated from a local high-water mark and are not comparable to forward_encoding_version). Projection catch-up is automatic on indexed reads and is distinct from readiness.
  • SetMetadataFieldType (retype) bumps the cluster-wide Index.forward_encoding_version. Each replica then runs a local rewrite into the new versioned keyspace (pending_version), with live writes dual-writing into both current_version and pending_version until the local rewrite finishes and atomically switches. Queries served from the replica's current_version stay 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: ⚠️ Different model — readiness is per-replica version state (IndexEntry.current_version / pending_version), with no cluster-wide ready flag on the wire.


Read Features

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.

Pagination

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.


Priority Recommendations

High Priority

  1. Import/Export - Critical for migration and backups

Low Priority

  1. Ledger config update - Can be done manually via recreation

Architecture Notes

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.


gRPC API

The POC provides a gRPC API for internal service communication (Raft node forwarding to leader) and can be used by clients.

Cluster node ID width

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.

Cluster membership wire contract

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.

BucketService Methods

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) ✅

Apply Method

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 ID
  • idempotency_key: Optional idempotency key (on the enclosing ApplyBatch — one per atomic batch)
  • skip_response: When true, strips log payloads from the response (only sequence is 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 transaction
    • add_metadata: Add metadata to an account or transaction
    • revert_transaction: Revert a transaction
    • delete_metadata: Delete metadata from an account or transaction
    • save_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 request scopes

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.go walks the Request.type and LedgerAction.data oneof 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 to ledger:WriteOps. Because DefaultMapping grants ledger:WriteOps to any ledger:write token but ledger:WriteCluster only to ledger:admin, a non-admin caller could create or delete query checkpoints through Apply — an operation the dedicated ClusterService RPCs restrict to admins. They now require ledger:WriteCluster. Callers driving query checkpoints through Apply with a ledger:write token must move to ledger:admin or add the granular ledger:WriteCluster scope.

Breaking change (EN-1954). BucketService.CreatePreparedQuery, UpdatePreparedQuery, DeletePreparedQuery and ClusterService.CreateQueryCheckpoint, DeleteQueryCheckpoint are removed. Every one of these operations is a ledger.Request variant submitted through BucketService.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 with skip_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.

gRPC Error Mapping

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)

REST/HTTP Error Mapping

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
            }
        }
    }
}

Reserved ledger names

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.

RC.4 operational policy

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.