Skip to content

feat!: expose color as a first-class Posting property#139

Open
gfyrag wants to merge 12 commits into
mainfrom
feat/color-on-posting
Open

feat!: expose color as a first-class Posting property#139
gfyrag wants to merge 12 commits into
mainfrom
feat/color-on-posting

Conversation

@gfyrag
Copy link
Copy Markdown
Contributor

@gfyrag gfyrag commented Jun 3, 2026

Summary

Color is now carried as a dedicated field on the output Posting type and
as a separate dimension throughout the interpreter, instead of being
encoded as an asset suffix (USD_RED). The numscript ↔ host contract
becomes a clean two-dimensional (asset, color) model: downstream
consumers (ledgers, indexers, reporting) can segregate balances by
(account, asset, color) without parsing asset strings.

The semantics already exercised by the experimental-asset-colors
feature flag are unchanged. Only the wire format and Go API change.

Breaking changes

Surface Before After
Posting {Source, Destination, Amount, Asset} + Color string (json "color")
BalanceQuery map[string][]string (asset strings, color encoded as suffix) map[string][]AssetColor with AssetColor{Asset, Color string}
Balances (in-memory) map[account]map[asset]*big.Int map[account]map[asset]map[color]*big.Int
Balances (JSON wire) flat {"alice": {"USD/2": 100}} strict colored {"alice": {"USD/2": {"": 100, "RED": 50}}}
coloredAsset() helper encoded ASSET_COLOR in asset strings removed
fundsQueue.Pull signature Pull(amount, color *string) Pull(amount) — the color filter parameter was dead code, dropped
fundsQueue.PullColored, PullUncolored unused wrappers removed

No backwards-compatibility shim. The flat-shorthand JSON shape
({"USD/2": 100}) is not accepted — every balance amount must be
written under an explicit color, with "" for the uncolored bucket. The
130 fixture .num.specs.json files in the repo were rewritten
accordingly in a single sweep (jq one-liner over the tree).

A small public helper Uncolored(amount) is added so building test
balances stays terse: Uncolored(big.NewInt(100)) produces
ColorBalance{"": amount}.

Why now

A consumer ledger (POC) needs strict color-segregated balances stored as
(account, asset, color) keys, with color flowing through the emitted
posting. As long as numscript strips color from postings and folds it
into the asset string, consumers cannot do that cleanly. This PR fixes
the contract upstream so we do not need to translate at the boundary.

What's covered by tests

  • color_semantics_test.go (black-box, 17 tests): color propagation
    through emitted postings, isolation between colors, source-restriction
    semantics, allocation distributing color across legs, send-all
    draining only the targeted color, JSON shape acceptance/rejection,
    charset enforcement (^[A-Z]*$).
  • funds_queue_color_test.go (white-box, 1 test): compactTop
    never merges senders across colors. The PullColored/PullUncolored
    selectivity tests are gone with their subjects — the upstream
    invariant (tryTakingFromAccount caps push at the per-color balance,
    tryTakingExact validates completeness on pull) makes the filter
    parameter unnecessary.
  • Updated balances_test.go, interpreter_test.go, numscript_test.go,
    specs_format/*_test.go, plus the 130 .num.specs.json fixtures.

Test plan

  • All numscript test packages green; just pre-commit clean.
  • Downstream consumer (ledger POC) integrates against this branch
    via a replace/pseudo-version pin and exercises segregated
    balances end-to-end (formancehq/ledger-v3-poc#234).
  • CodeRabbit's 3 actionable comments addressed:
    - Color + with scaling (Critical): the grammar already
    refuses the combination, and the SourceWithScaling.Color
    field is never populated by the parser — leaving as-is.
    - HasPrefix exact-match in getAssets (Major): fixed in
    stacked PR #140
    on top of failing test #141.
    - MCP float64 → big.Int truncation (Major): fixed in
    stacked PR #143
    on top of failing test #142.

🤖 Generated with Claude Code

Color is now carried as a dedicated field on the output Posting type and as
a separate dimension throughout the interpreter, instead of being encoded as
an asset suffix (`USD_RED`). This makes the numscript ↔ host contract clean:
downstream consumers (ledgers, indexers, reporting) can segregate balances
by (account, asset, color) without having to parse asset strings.

BREAKING CHANGES:
- Posting gains `Color string` (json `"color"`).
- BalanceQuery shape: `map[string][]string` → `map[string][]AssetColor`
  where `AssetColor{Asset, Color string}`. Store implementations must
  honor color as a separate filter rather than splitting suffixed assets.
- Balances shape: `map[account]map[asset]*big.Int` →
  `map[account]map[asset]map[color]*big.Int`. The (asset, color) pair is
  now an explicit key. `Balances.UnmarshalJSON` accepts both the new
  colored form and the legacy `{asset: amount}` shorthand (parsed as the
  uncolored bucket) so existing JSON fixtures keep working without a
  forced migration.
- `coloredAsset()` helper removed; nothing in the public or internal API
  encodes color into the asset string anymore.
- New public helper `Uncolored(amount)` to ergonomically build a
  single-bucket `ColorBalance` in tests / static fixtures.

The semantics already exercised by the experimental-asset-colors feature
flag stay the same — only the wire format changes. Existing colored .num
specs were migrated to the new shape and now assert on `posting.color` in
addition to `posting.asset`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

Linter diff in the way? Review this PR in Change Stack to focus on meaningful changes and expand context only when needed.

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3837a45a-f90d-406a-a69c-24a006e18a25

📥 Commits

Reviewing files that changed from the base of the PR and between f8b9593 and 649546a.

⛔ Files ignored due to path filters (5)
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-inorder-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-inorder.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/no-double-spending-in-colored-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/no-double-spending-in-colored-send.num.specs.json is excluded by !**/*.json
  • internal/specs_format/__snapshots__/runner_test.snap is excluded by !**/*.snap, !**/*.snap
📒 Files selected for processing (2)
  • internal/interpreter/color_semantics_test.go
  • internal/interpreter/interpreter.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/interpreter/interpreter.go

Walkthrough

This PR makes balances color-aware: balances map account→asset→color→amount, queries use AssetColor, postings carry Color, JSON marshal/unmarshal and pretty-printing are updated, the funds queue and batching are refactored, and tests and tooling are updated for the new model.

Changes

Asset-Color Balance Model

Layer / File(s) Summary
Core types and data model
internal/interpreter/interpreter.go
AssetColor struct added, BalanceQuery now requests []AssetColor, AccountBalance becomes per-asset→color→*big.Int, Posting gains Color field.
Balance value operations
internal/interpreter/balances.go
Uncolored() helper; DeepClone, fetchBalance, has, filterQuery, Merge, PrettyPrint, CompareBalances, CompareBalancesIncluding updated to operate on nested color maps.
Balances JSON (read/write)
internal/interpreter/balances_json.go
Canonical marshal and tolerant unmarshal supporting number, object{amount[,color]}, or array-of-value-objects; validation and deterministic output.
Balances JSON input and MCP parsing
internal/mcp_impl/handlers.go, scripts/migrate-specs-to-color.sh
MCP parsing replaced with JSON round-trip into interpreter.Balances; migration script transforms legacy nested maps to value-object forms.
Balance query and retrieval
internal/interpreter/interpreter.go, internal/interpreter/batch_balances_query.go, internal/interpreter/asset_scaling.go
Batching and StaticStore use AssetColor; getAssets reads uncolored bucket for scaling; TestInitStore and test fixtures updated to populate per-color amounts.
Posting generation & pretty print
internal/interpreter/interpreter.go
Postings emit Asset and Color separately; PrettyPrintPostings shows Color column only when present.
Funds queue behavior
internal/interpreter/funds_queue.go
Remove color-filtered Pull API, add PullAnything and simplify Pull to drain by queue head while preserving Sender.Color.
Test infrastructure & wiring
internal/interpreter/interpreter_test.go, internal/cmd/test_init.go, internal/specs_format/index.go
Test harness supports coloredBalances and builtBalances merger; fetchOrInsertBalance helper added for nested pointers; tests updated to use Uncolored(...).
Balance ops tests
internal/interpreter/balances_test.go
Unit tests updated/extended to assert color-aware filtering, cloning, merging, comparison, lazy fetch, and Uncolored helper.
Color semantics behavioral suite
internal/interpreter/color_semantics_test.go
New comprehensive tests validate color propagation, isolation, immutability, compaction, allocations, send-all semantics, charset rules, JSON shapes, and pretty-print behavior.
Funds queue tests
internal/interpreter/funds_queue_color_test.go, internal/interpreter/funds_queue_test.go
New white-box compaction test ensures merging respects Name+Color; other queue tests updated to new Pull/PullAnything API.
Downstream fixture updates
numscript.go, numscript_test.go, internal/specs_format/*, internal/cmd/test_init_test.go
Re-exports and tests updated to use AssetColor{Asset:...} queries and Uncolored(...) balance values in expectations.

🎯 4 (Complex) | ⏱️ ~60 minutes

"I hopped through ledgers, nose a-gleam,
Each color found its own small stream.
Coins now wear coats of painted hues,
A rabbit cheers the tidy news! 🐇🎨🍬"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat!: expose color as a first-class Posting property' directly describes the main change: making color a dedicated field on the Posting type instead of encoding it in the asset string.
Description check ✅ Passed The description comprehensively documents the changes, including a summary of the color refactoring, a detailed table of breaking changes, testing coverage, and test plan results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/color-on-posting

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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 and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 3, 2026

Codecov Report

❌ Patch coverage is 82.11009% with 39 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.79%. Comparing base (5cc059f) to head (649546a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
internal/mcp_impl/handlers.go 0.00% 19 Missing ⚠️
internal/interpreter/balances_json.go 77.77% 8 Missing and 8 partials ⚠️
internal/interpreter/asset_scaling.go 50.00% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #139      +/-   ##
==========================================
+ Coverage   66.96%   67.79%   +0.82%     
==========================================
  Files          47       48       +1     
  Lines        5068     5192     +124     
==========================================
+ Hits         3394     3520     +126     
+ Misses       1477     1467      -10     
- Partials      197      205       +8     

☔ 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gfyrag gfyrag marked this pull request as ready for review June 3, 2026 10:56
@gfyrag gfyrag requested review from Azorlogh and ascandone June 3, 2026 10:56
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/interpreter/asset_scaling.go`:
- Around line 46-55: The loop that selects assets uses strings.HasPrefix(asset,
baseAsset) which wrongly includes assets like "USDT" for baseAsset "USD"; update
the condition in the loop (the block iterating over balance and referencing
getAssetScale and result) to accept only the exact base asset or children in the
form baseAsset + "/" (i.e., asset == baseAsset || strings.HasPrefix(asset,
baseAsset + "/")), leaving the rest of the logic (retrieving amount from
colorMap, calling getAssetScale, and assigning result[scale]) unchanged.
- Around line 41-55: getAssets is dropping the color dimension by hardcoding
colorMap[""], which allows colored scaling paths to improperly use uncolored
buckets; fix by making getAssets color-aware: change its signature to accept a
color string (e.g., getAssets(balance AccountBalance, baseAsset, color string)
map[int64]*big.Int), look up amount := colorMap[color] instead of colorMap[""],
and propagate this new parameter to all callers (or alternatively validate and
reject non-empty colors before calling getAssets if you prefer to keep the old
signature); ensure callers in scaling lookup pass source.Color so the correct
color bucket is used.

In `@internal/mcp_impl/handlers.go`:
- Around line 40-57: The code truncates float64 to *big.Int (via
big.NewFloat(...).Int) for both the top-level amount (perAssetRaw -> amount) and
per-color amounts (colorMap -> amountRaw -> amount), allowing fractional values
like 100.9 and losing precision for large integers; change the parsing to
require exact integers: use json.Number (or decoder.UseNumber) or otherwise
validate that the incoming numeric value is an integer (no fractional part and
within exact range) before converting to big.Int, and return a
NewToolResultError when a non-integer or out-of-range value is encountered;
update the branches handling perAssetRaw and amountRaw and ensure conversion to
big.Int uses a string-to-big.Int path (e.g., SetString) rather than
big.NewFloat(...).Int to avoid truncation and rounding issues.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3ea33a0b-01a1-4d14-9fef-bd22c31487a7

📥 Commits

Reviewing files that changed from the base of the PR and between 5cc059f and f4fae45.

⛔ Files ignored due to path filters (12)
  • internal/interpreter/__snapshots__/balances_test.snap is excluded by !**/*.snap, !**/*.snap
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-inorder-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-inorder.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-restrict-balance-when-missing-funds.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-restrict-balance.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-restriction-in-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-send-overdrat.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-send.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-with-asset-precision.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/no-double-spending-in-colored-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/no-double-spending-in-colored-send.num.specs.json is excluded by !**/*.json
  • internal/specs_format/__snapshots__/runner_test.snap is excluded by !**/*.snap, !**/*.snap
📒 Files selected for processing (16)
  • internal/cmd/test_init.go
  • internal/cmd/test_init_test.go
  • internal/interpreter/asset_scaling.go
  • internal/interpreter/balances.go
  • internal/interpreter/balances_test.go
  • internal/interpreter/batch_balances_query.go
  • internal/interpreter/color_semantics_test.go
  • internal/interpreter/funds_queue_color_test.go
  • internal/interpreter/interpreter.go
  • internal/interpreter/interpreter_test.go
  • internal/mcp_impl/handlers.go
  • internal/specs_format/index.go
  • internal/specs_format/parse_test.go
  • internal/specs_format/run_test.go
  • numscript.go
  • numscript_test.go

Comment on lines +41 to +55
// getAssets returns, for a given baseAsset, the per-scale uncolored balance.
// Asset scaling operates on the uncolored bucket only — colored funds are not
// implicitly converted across scales.
func getAssets(balance AccountBalance, baseAsset string) map[int64]*big.Int {
result := make(map[int64]*big.Int)
for asset, amount := range balance {
if strings.HasPrefix(asset, baseAsset) {
_, scale := getAssetScale(asset)
result[scale] = amount
for asset, colorMap := range balance {
if !strings.HasPrefix(asset, baseAsset) {
continue
}
amount, ok := colorMap[""]
if !ok {
continue
}
_, scale := getAssetScale(asset)
result[scale] = amount
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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Don't drop the color dimension during scaling lookup.

getAssets now hardcodes colorMap[""], but scaling sources still accept source.Color. That means a colored scaling path can convert uncolored balances and then withdraw nothing from the requested color bucket, which is enough for send all to emit bogus intermediary conversion postings. Either make this helper color-aware or reject non-empty colors before calling it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/interpreter/asset_scaling.go` around lines 41 - 55, getAssets is
dropping the color dimension by hardcoding colorMap[""], which allows colored
scaling paths to improperly use uncolored buckets; fix by making getAssets
color-aware: change its signature to accept a color string (e.g.,
getAssets(balance AccountBalance, baseAsset, color string) map[int64]*big.Int),
look up amount := colorMap[color] instead of colorMap[""], and propagate this
new parameter to all callers (or alternatively validate and reject non-empty
colors before calling getAssets if you prefer to keep the old signature); ensure
callers in scaling lookup pass source.Color so the correct color bucket is used.

Comment on lines +46 to +55
for asset, colorMap := range balance {
if !strings.HasPrefix(asset, baseAsset) {
continue
}
amount, ok := colorMap[""]
if !ok {
continue
}
_, scale := getAssetScale(asset)
result[scale] = amount
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use exact base-asset matching here.

strings.HasPrefix(asset, baseAsset) will treat unrelated assets like USDT as part of the USD scaling pool. Match only the base asset itself or baseAsset/… children.

Suggested fix
 	for asset, colorMap := range balance {
-		if !strings.HasPrefix(asset, baseAsset) {
+		if asset != baseAsset && !strings.HasPrefix(asset, baseAsset+"/") {
 			continue
 		}
 		amount, ok := colorMap[""]
 		if !ok {
 			continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/interpreter/asset_scaling.go` around lines 46 - 55, The loop that
selects assets uses strings.HasPrefix(asset, baseAsset) which wrongly includes
assets like "USDT" for baseAsset "USD"; update the condition in the loop (the
block iterating over balance and referencing getAssetScale and result) to accept
only the exact base asset or children in the form baseAsset + "/" (i.e., asset
== baseAsset || strings.HasPrefix(asset, baseAsset + "/")), leaving the rest of
the logic (retrieving amount from colorMap, calling getAssetScale, and assigning
result[scale]) unchanged.

Comment thread internal/mcp_impl/handlers.go Outdated
@gfyrag gfyrag marked this pull request as draft June 3, 2026 11:05
gfyrag and others added 2 commits June 3, 2026 13:11
Per ledger #234 feedback: the dual JSON parse path was a backward-compat
shim that masked the schema break. With the new (asset, color) keying,
the only canonical shape is

    {"<account>": {"<asset>": {"<color>": <amount>, ...}}}

where color "" is the uncolored bucket. The flat shorthand
\`{"<asset>": <amount>}\` is no longer accepted; balances must spell out
the empty-color key explicitly.

Changes:
- Remove \`Balances.UnmarshalJSON\` and \`decodeColorBalance\`. Go's default
  JSON unmarshal walks the natural 3-level map directly.
- \`mcp_impl.parseBalancesJson\` rejects the shorthand with a clearer
  error message (the helper still hand-parses a generic \`map[string]any\`
  coming from the MCP transport).
- Sweep the 121 \`.num.specs.json\` fixtures via jq to convert every
  \`"<asset>": <number>\` to \`"<asset>": {"": <number>}\`. The walk only
  touches \`balances\` (top-level and \`testCases[].balances\`) — movements
  and post-commit volumes keep their native shapes.
- Update \`specs_format\` table tests for the new shape (run_test.go,
  runner_test.go, parse_test.go).
- Refresh the runner_test snapshot (\`TestSchemaErrSpecs\` now reports
  the friendlier \`cannot unmarshal number into ColorBalance\` instead of
  going through the bespoke shorthand path).
- Update \`color_semantics_test.go\`: a single \`TestBalancesJSONShape\`
  asserts the canonical parse, plus \`TestBalancesJSONRejectsFlatShorthand\`
  pins the rejection.

All numscript test packages green; pre-commit clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… invariant

The Pull(amount, color *string) signature accepted an optional color
filter that is never used by the interpreter — the only caller is
pushReceiver, which always passes nil through PullAnything. Whatever
balance check a colored source needs has already happened in
tryTakingFromAccount before any Sender is pushed onto the queue:
CalculateSafeWithdraw caps the pushed amount at the source's
(asset, color) balance, and tryTakingExact raises MissingFundsErr if
the queue can't supply the requested total.

Cleaning up:
- Pull's signature is now Pull(requiredAmount *big.Int) — the unused
  color filter branch is removed and the function gets a doc-comment
  spelling out the upstream-bounded / caller-validated contract.
- PullColored and PullUncolored wrappers are dropped (no in-tree
  callers outside the tests that exercised them directly).
- funds_queue_test.go: the TestPullColored* + TestReconcileColored*
  tests are removed (they covered the removed branch). TestPush
  switches from PullUncolored to PullAnything (functionally identical).
- funds_queue_color_test.go: collapses to a single
  TestFundsQueueCompactDoesNotMergeAcrossColors that still pins the
  invariant we actually care about — compactTop never merges across
  colors. The PullColored / PullUncolored selectivity tests went away
  with their subject.

No behavioral change for the interpreter: the removed branch was
unreachable from anywhere except the deleted tests, and all
color-segregation integration tests (color_semantics_test.go,
testdata/script-tests/experimental/asset-colors/) still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gfyrag added a commit that referenced this pull request Jun 3, 2026
`getAssets(balance, baseAsset)` uses `strings.HasPrefix(asset, baseAsset)`,
which accidentally matches any asset whose key shares the base name as a
string prefix:

  - `USDT` and `USDT/2` are matched as scaled variants of `USD`
  - `USD_RED` and `USD_RED/2` (suffix-encoded color variants emitted by
    the experimental asset-colors feature flag) are also matched

Worse: because the result map is keyed by precision scale only, an entry
like `USD_RED/2 = 500` overwrites the true `USD/2 = 2` value in the
scaling pool. A user holding both `USD/2 = 2` and `USD_RED/2 = 500` on
the same account will see scaling compute conversions against the wrong
amount — silent corruption.

This commit introduces a failing regression test
`TestGetAssetsRejectsSpuriousPrefixMatches` that pins the bug. CI is
intentionally red on this branch.

The fix is in a stacked PR that targets this branch — merging it
flips the test green.

Flagged by CodeRabbit during review of #139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gfyrag gfyrag marked this pull request as ready for review June 3, 2026 12:21
@gfyrag gfyrag requested a review from altitude June 3, 2026 12:29
Comment thread internal/mcp_impl/handlers.go Outdated
if !ok {
return interpreter.Balances{}, mcp.NewToolResultError(fmt.Sprintf("Expected float for amount: %v", amountRaw))
}
n, _ := big.NewFloat(amount).Int(new(big.Int))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This still converts JSON numbers through float64 and then big.NewFloat(...).Int(...), which silently truncates fractions and can round large integers before conversion. Since balances are ledger amounts, this should reject non-integer or non-exact JSON numbers, or include the validation from PR #143 before #139 is merged standalone.

_, scale := getAssetScale(asset)
result[scale] = amount
for asset, colorMap := range balance {
if !strings.HasPrefix(asset, baseAsset) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This matches any asset starting with baseAsset, so getAssets(balance, "USD") also includes assets like USDC or USDX/2 in the scaling pool. The match should be exact base asset or baseAsset + "/"; otherwise unrelated asset balances can be converted during asset scaling.

Comment thread internal/mcp_impl/handlers.go Outdated

n, _ := big.NewFloat(amount).Int(new(big.Int))
iBalances[account][asset] = n
// Expected shape: { "USD/2": { "": 100, "RED": 50 } }.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The parser now expects the strict colored shape, but the MCP evaluate tool description still documents the old flat shape ({"USD/2": 100}). Callers following the advertised schema will get runtime errors. Please update the tool description/example to show {"USD/2": {"": 100}} and mention that "" is the uncolored bucket.

Comment thread internal/interpreter/interpreter.go Outdated
Destination string `json:"destination"`
Amount *big.Int `json:"amount"`
Asset string `json:"asset"`
Color string `json:"color"`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since Color is now part of the posting contract, pretty output should expose it too. PrettyPrintPostings still renders only source, destination, asset, and amount, so numscript run --output-format pretty and spec failure output can hide the only difference between two colored postings.

gfyrag added 4 commits June 6, 2026 08:53
The previous shape forced every balance entry into a nested
{color: amount} map and rejected the bare-number shorthand. Two problems
surfaced in the PR thread:

1. `""` is an awkward JSON key for the uncolored bucket — it leaks an
   internal representation choice into every specs.json / inputs.json.
2. Adding orthogonal dimensions later (scopes, scales) would require
   another level of nesting and break readers again.

The wire format becomes value-object based:

  - bare integer for the uncolored shorthand:       "USD/2": 100
  - single value-object:                            "USD/2": { "color": "RED", "amount": 50 }
  - array of value-objects (canonical multi-color): "USD/2": [{ "amount": 100 }, { "color": "RED", "amount": 50 }]

Reading is tolerant of all three forms; writing always emits the
canonical form (bare number when uncolored-only, sorted array
otherwise). Future dimensions land as new fields on the value-object,
not as new map levels.

The 116 .num.specs.json fixtures + inline test strings are updated.
JSON schemas (specs / inputs) gain a BalanceEntry definition.
When color is part of the Posting contract but absent from the pretty
printer, two postings that differ only by color render identically —
the spec failure output (and `numscript run -o pretty`) hide the only
distinguishing field.

The Color column is added conditionally: present as soon as any posting
in the batch carries a non-empty color; hidden otherwise so the
uncolored output is byte-identical to the previous behaviour.
parseBalancesJson now re-marshals the decoded MCP payload through
Balances.UnmarshalJSON, so the tool accepts the same three forms
documented elsewhere (bare number / value-object / array). The decoded
amount path goes via *big.Int directly rather than the previous
float64 → big.Int truncation.

The tool description and example block are rewritten to match.
Existing downstream specs files using {color: amount} maps can be
upgraded in place with `scripts/migrate-specs-to-color.sh path/...`.
The jq rules mirror the canonical write form: uncolored-only collapses
to a bare number, single color emits one value-object, multi-color
emits an array sorted by color.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
scripts/migrate-specs-to-color.sh (1)

56-61: 💤 Low value

Temp file not cleaned up on jq failure.

If jq fails (e.g., malformed JSON), the script exits due to set -e but leaves the temp file behind. For a one-off migration script this is acceptable, but consider using a trap for cleanup.

🛠️ Optional improvement
 for f in "$@"; do
   tmp="$(mktemp)"
+  trap 'rm -f "$tmp"' EXIT
   jq --indent 2 "$JQ_FILTER" "$f" > "$tmp"
   # Preserve trailing newline behaviour of jq output.
   mv "$tmp" "$f"
+  trap - EXIT
 done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/migrate-specs-to-color.sh` around lines 56 - 61, The script creates a
per-file temp file in the loop using tmp="$(mktemp)" and can leave that temp
behind if jq fails (set -e); add a cleanup trap to remove temp files on exit.
Implement a small mechanism (e.g., an array TMP_FILES or a function
register_tmp) to record each tmp created, set trap 'rm -f "${TMP_FILES[@]:-}"'
EXIT before the loop, and ensure you append each tmp to TMP_FILES right after
mktemp; then continue writing jq output to "$tmp" and mv on success as before so
any tmp is removed on script exit or failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@scripts/migrate-specs-to-color.sh`:
- Around line 56-61: The script creates a per-file temp file in the loop using
tmp="$(mktemp)" and can leave that temp behind if jq fails (set -e); add a
cleanup trap to remove temp files on exit. Implement a small mechanism (e.g., an
array TMP_FILES or a function register_tmp) to record each tmp created, set trap
'rm -f "${TMP_FILES[@]:-}"' EXIT before the loop, and ensure you append each tmp
to TMP_FILES right after mktemp; then continue writing jq output to "$tmp" and
mv on success as before so any tmp is removed on script exit or failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 17264693-5a68-489a-aa95-482f529ac13a

📥 Commits

Reviewing files that changed from the base of the PR and between f4fae45 and f8b9593.

⛔ Files ignored due to path filters (47)
  • inputs.schema.json is excluded by !**/*.json
  • internal/interpreter/testdata/numscript-cookbook/experimental/mixed-source-prefer-single-source.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/numscript-cookbook/experimental/top-up-many.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/numscript-cookbook/experimental/top-up.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/numscript-cookbook/experimental/transfer-example.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/numscript-cookbook/send-max.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/balance-not-found.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/bigint-literal.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/empty-postings.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-inorder-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-inorder.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-restrict-balance-when-missing-funds.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-restrict-balance.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/color-restriction-in-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/no-double-spending-in-colored-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-colors/no-double-spending-in-colored-send.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/no-solution.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-all-allotment.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-allotment.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-kept.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-with-oneof.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/asset-scaling/update-swap-account-balance.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/mid-script-function-call/expr-in-var-origin.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/oneof/oneof-all-failing.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/overdraft-function/overdraft-function-when-positive.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/overdraft-function/overdraft-function-when-zero.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/experimental/overdraft-function/reach-zero.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/feature-flag-syntax.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/floating-perc.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/floating-perc2.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/minus-infix-monetary.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/minus-infix-number.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/minus-prefix-number.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/neg-max-dest.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/negative-max-send-all.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/send-all-when-negative.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/send-allt-max-when-no-amount.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/send-zero.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/zero-postings-explicit-allotment.num.specs.json is excluded by !**/*.json
  • internal/interpreter/testdata/script-tests/zero-postings-explicit-inorder.num.specs.json is excluded by !**/*.json
  • internal/specs_format/__snapshots__/runner_test.snap is excluded by !**/*.snap, !**/*.snap
  • specs.schema.json is excluded by !**/*.json
📒 Files selected for processing (9)
  • internal/interpreter/balances.go
  • internal/interpreter/balances_json.go
  • internal/interpreter/color_semantics_test.go
  • internal/interpreter/funds_queue.go
  • internal/interpreter/funds_queue_color_test.go
  • internal/interpreter/funds_queue_test.go
  • internal/interpreter/interpreter.go
  • internal/mcp_impl/handlers.go
  • scripts/migrate-specs-to-color.sh
💤 Files with no reviewable changes (1)
  • internal/interpreter/balances.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/interpreter/interpreter.go

gfyrag added 2 commits June 6, 2026 09:26
`"color": ""` in every uncolored posting is noise: an absent color
field carries the same meaning as the empty-string bucket, so emitting
it leaks an internal convention into every JSON payload.

With omitempty, uncolored postings (the dominant case) serialize as
they did before color became first-class. Round-trip stays correct
because Go's zero-value for the missing field is the empty string,
which is exactly the uncolored-bucket key on the decode side.
The fixtures that exercise the experimental color split kept the
`"color": ""` entry on uncolored postings — a leftover from the first
migration pass when `COIN_BLUE` was split into `asset: COIN` +
`color: <suffix>`. With Posting.Color now `omitempty`, the runtime no
longer emits the empty field, so the assertion fixtures shouldn't either.
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