Skip to content

feat!: color of money — segregated balances per (account, asset, color)#1438

Open
gfyrag wants to merge 9 commits into
release/v3.0from
feat/color-of-money
Open

feat!: color of money — segregated balances per (account, asset, color)#1438
gfyrag wants to merge 9 commits into
release/v3.0from
feat/color-of-money

Conversation

@gfyrag

@gfyrag gfyrag commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces strict balance segregation by color as a first-class
dimension of the ledger. A balance lives at (account, asset, color);
postings carry an explicit color field. The empty color is the
uncolored bucket and is itself fully segregated from every colored
bucket.

Companion PR upstream: formancehq/numscript#139Posting.Color exposed in numscript output, BalanceQuery refactored to carry color as a separate dimension instead of an asset suffix. The ledger pins that branch via a local replace in go.mod for now; bump to a tagged release once #139 is merged.

Invariants

  1. Color is immutable. Once funds carry a color, no operation
    changes it. Re-coloring is a deliberate composition (clearing
    account + mint from @world), not a primitive.
  2. Empty color is a bucket. Color == "" participates in
    segregation just like any colored bucket. A draw under color X
    cannot dip into uncolored funds, and vice versa.
  3. ^[A-Z]*$ color charset, enforced both upstream (numscript) and
    at the ledger admission boundary. The empty string remains valid.
  4. Double-entry per bucket. For every (asset, color), the sum of
    all account balances is zero. Each bucket is its own conservation
    universe; the scenariotest invariant check is updated accordingly.

Wire shapes

  • Posting.color exposed everywhere postings appear.
  • Account.volumes becomes repeated AccountVolume{asset, color, volumes}
    sorted by (asset, color) ascending — deterministic JSON.
  • PostCommitVolumes inner shape becomes a sorted list of
    VolumeEntry{asset, color, volumes}.
  • AggregatedVolume.color is set on every entry; the default response
    is one entry per (asset, color) tuple.

For consumers that don't care about color, two opt-in flags let the
server collapse colors into a single per-asset entry:

  • GET /{ledger}/accounts/{address}?collapseColors=true
  • GET /{ledger}/volumes?collapseColors=true (or
    AggregateVolumesRequest.collapse_colors = true)

Storage layout

domain.VolumeKey carries Color string. Canonical bytes:

```
[ledgerID BE 4B] [account] \x00 [color] \x00 [asset_base] [precision 1B]
```

Color sits between account and asset so prefix scans behave naturally:
`[ledgerID][account]\x00` still returns every (color, asset) for the
account; `[ledgerID][account]\x00[color]\x00` is a fixed-prefix lookup
for a specific bucket. The precision byte stays trailing where it can
legitimately be `0x00` (e.g. "EUR") without encoding ambiguity.

v2 compat / mirror

Untouched by design. The v2 protocol has no color dimension, so every
mirrored posting lands in the uncolored bucket. The MirrorIngest
preload branch explicitly passes color = "" with a comment that
makes the intent visible at the call site.

Numscript integration

The numscript interpreter (PR #139) emits postings with their Color
field set. The ledger's numscriptStoreAdapter honors the new
AssetColor items in BalanceQuery and returns Balances keyed by
map[account][asset][color] -> *big.Int. No asset-suffix encoding
remains in either repo.

Tests

  • New tests/e2e/business/color_segregation_test.go — full
    end-to-end gRPC test covering GetAccount segregation, collapse,
    draw refusal across colors, draining a single bucket, color visible
    on the emitted posting.
  • New internal/application/ctrl/store_color_test.go covering
    assembleAccount segregation by default + collapseColors mode.
  • New aggregator color tests (internal/query/aggregate_test.go):
    segregation, collapse, collapse + max_precision, grouped aggregator.
  • New VolumeKey byte-format and round-trip tests for the colored
    layout in internal/domain/keys_test.go.
  • 30+ existing E2E/scenario tests migrated from
    acct.GetVolumes()["USD"] map access to acct.FindVolume("USD", "").
  • 57 unit-test packages green; e2e and scenario builds compile clean.

Test plan

  • CI green
  • Numscript PR Simple batch #139 reviewed and merged, this branch retargeted to a
    tagged numscript release (drop the replace directive)
  • Manual smoke via `ledgerctl transactions create --posting "world,alice,100,USD/2,GRANTS"`
    and `ledgerctl accounts get alice` showing the COLOR column

🤖 Generated with Claude Code


Migrated from formancehq/ledger-v3-poc#234 on 2026-06-29.

gfyrag added 8 commits June 29, 2026 16:32
Squashed from 30+ iterations. Color is a first-class dimension on every
balance, posting, audit order, and read-side projection.

- Posting.Color, AccountVolume keyed by (account, asset, color)
- Numscript scripts default to "" color; explicit color via syntax
- Audit orders carry color through transient/purged exclusion sets
- check/ verifies color through excludedVolumesSet, partitionVolumes
- REST/gRPC/JSON always emit color (including uncolored bucket)
- analysis carries color through flow signatures, NUL-separated to
  prevent collisions
- admission validates Posting.Color before preload extraction

Will require manual rebase on top of main (post-master→main migration,
metadata-immutability refactor, AppliedProposal envelopes, etc.).
…signedApplyRequest

- idempotency_failure_test.go used the old map-style Account.Volumes
  lookup; switch to FindVolume("USD", ""). The e2e package fails
  the build under `-tags e2e` otherwise.
- color_segregation_test.go / color_numscript_test.go: master replaced
  the inline `&servicepb.ApplyRequest{Envelopes: UnsignedEnvelopes(...)}`
  builder with the `servicepb.UnsignedApplyRequest("", ...)` helper.
  Migrate every Apply call accordingly.

Addresses NumaryBot review on common.proto:148.
The sink JSON now always emits posting.color (commit 60aef1a18 dropped
omitempty for the proto type, b858501db did the same for sinkPosting).
The ClickHouse table DDL still declared posting columns as
source/destination/amount/asset only, so colored postings would
either be rejected by strict JSON typing or land in untyped/dynamic
storage depending on ClickHouse settings.

Add the color column so warehouse queries can reconstruct the
segregated buckets exactly as the API surfaces them. Databricks is
unaffected — its DDL uses STRING for the whole data column.

Addresses NumaryBot review on sink_data_common.go:73.
…hape

The antithesis workload module replaces ledger via go.mod, so it
consumes our proto directly. The volume API changed from map<string,
*> to repeated entries:

- reads.go accountAssetVolumes: switch to acct.FindVolume(asset, "").
- validate.go postCommitVolume: scan VolumesByAssets.volumes for the
  (asset, color="") tuple.

The workload only ever exercises uncolored postings, so both call
sites match the uncolored bucket explicitly — colored buckets stay
out of scope for this driver model.

Addresses NumaryBot reviews on common.proto:109 and :148.
Master landed several major refactors that intersect color-of-money:
- #573 (EN-1378): admission now emits Declare for absent volume keys
  and Scope.Volumes().Get returns ErrNotFound which readVolumeOrZero
  turns into a fresh zero balance. Drop our explicit ErrBalanceNotPreloaded
  branches around readVolumeOrZero; replace the no-longer-applicable
  TestGetBalances_VolumeNotFound_ReturnsError with the new contract test
  TreatedAsZero. Adapt the numscript adapter accordingly.
- #569: Scope per-attribute trios collapsed into generic Accessor[K,V,R].
  Switch buildPostCommitVolumes to s.Volumes().Get(...).
- #587 (EN-868): business API now mounts under /v3 only. Update the
  collapseColors HTTP test URL accordingly.
- #571 (mockgen helpers): pick up master's expectGetVolume / expectPutVolume
  helpers in tests.
- Fan out the additional cfg/color parameters that processor and
  indexer signatures accumulated across master commits.
@gfyrag gfyrag requested a review from Azorlogh as a code owner June 29, 2026 14:33
@gfyrag gfyrag requested a review from ascandone June 29, 2026 14:33
@NumaryBot

NumaryBot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🛑 Changes requested — automated review

The PR introduces a breaking change to the persisted volume-key layout (adding a color segment to the Pebble key) without bumping the storage schema version or providing a migration path. Existing stores will silently pass boot validation yet produce incorrect or missing balance reads when the new VolumeKey.Unmarshal encounters the old key format. This is a confirmed blocker; no other distinct findings were identified across the reviews.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NumaryBot posted 1 new inline finding.

Summary: #1438 (comment)

Comment thread internal/domain/keys.go
@@ -102,6 +123,8 @@ func (bk VolumeKey) AppendBytes(dst []byte) []byte {
dst = appendLedgerName(dst, bk.LedgerName)
dst = append(dst, bk.Account...)
dst = append(dst, dal.CanonicalKeySepVolume)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [blocker] Bump the storage schema for colored volume keys

When opening an existing schema-version-1 store, this new extra separator changes the Pebble volume key layout from [account]\0[asset][precision] to [account]\0[color]\0[asset][precision], but CurrentStorageSchemaVersion is still 1. Those stores will pass boot validation and then old volume keys either fail VolumeKey.Unmarshal or are scanned as malformed/missing, so existing balances and checker/index reads can be lost or fail instead of being rejected up front or migrated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix — same compat concern reposted 28 times on the previous PR (formancehq/ledger-v3-poc#234) before this branch was moved here. ledger-v3-poc convention is no compat shims and no rolling-upgrade contract on this branch; existing v1 stores must be wiped before the cut-over.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 32ba637d-ab8b-4d36-87c7-0f715f4b31da

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/color-of-money

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two adjacent NumaryBot findings:

1. ctrl/store.go: buildAccountVolumes did a silent continue on a
   malformed canonical volume key, dropping balances from GetAccount
   without telling the caller. Every other Pebble scan path propagates
   unmarshal errors; this one was inconsistent. Surface a hard error
   instead — CLAUDE.md invariant #7 (no silent should-not-happen).
   assembleAccount/scanAccount now return the error up the chain;
   buildAccountVolumes too. New TestAssembleAccount_MalformedKeyReturnsError
   pins the contract with a too-short canonical key payload.

2. receipt/receipt.go: PostingClaim.Color had json:"color,omitempty"
   so the signed JWT dropped color:"" for uncolored postings, making
   v3 uncolored claims indistinguishable from pre-color claims. Drop
   omitempty to match the contract already enforced by
   commonpb.Posting / sinkPosting / AccountVolume. New
   TestPostingClaim_AlwaysEmitsColor pins the JSON shape.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NumaryBot posted 1 new inline finding.

Summary: #1438 (comment)

Comment thread internal/domain/keys.go
@@ -102,6 +123,8 @@ func (bk VolumeKey) AppendBytes(dst []byte) []byte {
dst = appendLedgerName(dst, bk.LedgerName)
dst = append(dst, bk.Account...)
dst = append(dst, dal.CanonicalKeySepVolume)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [blocker] Storage schema version not bumped for colored volume key layout change
reported by NumaryBot, codex

The volume key layout has changed from [account]\0[asset][precision] to [account]\0[color]\0[asset][precision], but CurrentStorageSchemaVersion remains at 1. Existing stores will pass boot validation and then either fail VolumeKey.Unmarshal or silently return incorrect/missing balances, as old keys are scanned as malformed rather than being rejected up front or migrated.

Suggestion: Bump CurrentStorageSchemaVersion to 2 (or the next appropriate value), add a migration path that re-encodes existing volume keys into the new colored format, and add a guard that refuses to open a store whose on-disk schema version does not match the expected version.

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.83398% with 120 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.04%. Comparing base (343a5cf) to head (4db2d5c).

Files with missing lines Patch % Lines
internal/query/aggregate.go 64.86% 13 Missing and 13 partials ⚠️
internal/domain/errors.go 56.25% 14 Missing ⚠️
internal/query/compact_account.go 41.66% 10 Missing and 4 partials ⚠️
...nal/application/indexbuilder/protowire_postings.go 38.09% 12 Missing and 1 partial ⚠️
internal/proto/commonpb/common.pb.json.go 58.33% 10 Missing ⚠️
...main/processing/processor_transaction_numscript.go 77.14% 7 Missing and 1 partial ⚠️
internal/proto/commonpb/volumes.go 70.00% 5 Missing and 1 partial ⚠️
internal/domain/processing/numscript/errors.go 0.00% 5 Missing ⚠️
internal/domain/keys.go 66.66% 2 Missing and 2 partials ⚠️
...nternal/domain/processing/processor_transaction.go 33.33% 2 Missing and 2 partials ⚠️
... and 8 more

❌ Your patch check has failed because the patch coverage (76.83%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@               Coverage Diff                @@
##           release/v3.0    #1438      +/-   ##
================================================
+ Coverage         73.89%   74.04%   +0.15%     
================================================
  Files               382      383       +1     
  Lines             39032    39207     +175     
================================================
+ Hits              28842    29032     +190     
+ Misses             7725     7681      -44     
- Partials           2465     2494      +29     
Flag Coverage Δ
e2e 74.04% <76.83%> (+0.15%) ⬆️
scenario 74.04% <76.83%> (+0.15%) ⬆️
unit 74.04% <76.83%> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants