Repository navigation
Parse the registrar and the label into Holding - #50
Conversation
A whole-instrument comparison needs PartialEq. Callers compare the id and the admin field by field today, which is easy to half-apply. The admin field also carries three names across the layers. The template calls the party registrar, InstrumentIdentifier calls it source, and the interface view calls it admin. This type mirrors the view, so the comment records the mapping where a reader meets it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The struct kept three of the payload's eight fields. It dropped registrar, so a caller could not tell two registrars' CBTC apart, and it dropped label, so nothing could filter by account. instrument_id becomes an InstrumentId built from the payload's registrar and instrument.id, which is verbatim what Holding.daml:57 writes into the V1 view. So this struct and active_contracts::get now compare the same two values. account_id holds the label. The parser reads label as required. Every registry-holding version from 0.0.1 declares the field, confirmed from DA's source and against all 519,386 active mainnet holdings, so a required read breaks no existing holding. An empty label stays a real unlabelled account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The eight workspace members read their version from workspace.package, so bumping it rewrites their entries in the lock file. Only those eight lines change. Leaving them out would dirty the tree on the next build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The breaking API change affects downstream consumers and warrants final human review.
Pull request overview
Updates Holding parsing to retain registrar-based instrument identity and account labels, with a breaking 0.8.0 API change.
Changes:
- Parses
registrarandlabel. - Adds
InstrumentIdequality support and tests. - Documents and versions the breaking change.
File summaries
| File | Summary |
|---|---|
crates/token/src/holding.rs |
Extends holding parsing and adds tests. |
crates/common/src/transfer.rs |
Adds InstrumentId equality and documentation. |
CHANGELOG.md |
Documents the breaking API change. |
Cargo.toml |
Bumps the workspace version. |
Cargo.lock |
Updates workspace package versions. |
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
InstrumentId has sat in common::transfer since the initial commit, because Transfer was the first type that needed it. It is not a transfer type. Allocations, splits, consolidations, holdings and the registry all refer to an instrument, and 19 files across three crates reach into common::transfer for it. The Token Standard declares InstrumentId in Splice.Api.Token.HoldingV1, so common::instrument now matches where the standard puts it. The fields and the JSON encoding do not change, and this release already breaks Holding, so there is no compatibility re-export to keep the old path alive. The type also derives Hash, so a caller can key a map by instrument. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A code review raised six findings on this branch. Four are addressed here; one was disproved and one needed no change. Holding.account_id becomes account_label. The field holds the template's label verbatim, and a label is not an account. A V2 account is owner, provider and id together, so a caller who compares this field to an Account.id skips the provider check active_contracts::matches_account performs. The name now makes that misuse read wrong at the call site. The test fixture carried an empty label, so parses_every_field_of_a_holding passed against a parser that ignored the payload and returned the default. The fixture now labels the account, and that test fails under the mutation. The changelog gained a migration line, because the shortest edit that compiles is h.instrument_id.id == "CBTC", which reintroduces the bug. It also said seven error messages where there are eight, and carried a release date three days before the release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Resolve the advertised account_id/implemented account_label mismatch and correct the active-contracts verification claim.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
crates/token/src/active_contracts.rs:86
- The verification text says
active_contracts::integration_get_by_partyand its_v2twin exerciseHolding::from_active_contract, but these tests only callactive_contracts::getand compare contract IDs. That path filters interface-view JSON inwantedand never invokes the concrete-payload parser; the parser is exercised elsewhere by consolidation tests, not by the two tests named here. Please correct this verification claim or add the promised direct parse assertion to these tests.
instrument: &common::instrument::InstrumentId,
- Files reviewed: 23/24 changed files
- Comments generated: 1
- Review effort level: Lite
The pilot asked whether a type called InstrumentId should have a field called id. It should: the Rust type mirrors the Daml one, and both field names are the JSON keys on the wire, so a rename would need a serde attribute that makes the two disagree. The interesting half is why the pair exists. The standard says id "MUST be unique and unambiguous per instrument admin" - per admin, not globally. So id alone does not name an instrument, which is exactly the defect this branch fixes. The comment records that where a reader meets the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment argued from a measurement: no active mainnet holding carries a provider. That is true and too weak, and it implied one could appear. It cannot, for this template. The ensure clause at Holding.daml:49 calls isRegistryAccount, which checks isNone account.provider, so the ledger refuses to create such a contract. That has held since registry-holding 0.3.0. The comment also now separates the account's provider from the payload's own top-level provider field, which is an observer party. A reader who wired that one into the account would build the wrong account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment argued from the ensure clause, which arrived in 0.3.0, and so implied an older holding might differ. It cannot. holdingV1Metadata builds the account with provider = None as a literal, so the field is never read from the payload, at any version. The ensure clause tests a constant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment said the account can never carry a provider, which reads as a property of the standard. It is a property of registry-holding 0.3.2. The Token Standard's Account has a provider, and DA has said it intends to use it in a later iteration. The citations now name 0.3.2 and its line numbers. The previous ones came from 0.3.0 and were eight lines out, though every claim still held: the account derivation is unchanged between the two, and TokenApiUtilsV2.daml is identical. The label claim extends to 0.3.2 for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #51 names the three construction sites that change when the registry populates the account provider, and says why we cannot build the fix now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hubagaspar91
left a comment
There was a problem hiding this comment.
[pragmatic] Review — 3 comments, all on doc comments. No code findings.
The parser change is sound, and this pass found no code defect. All three comments below concern the new doc comments.
One general request. Several new comments cite exact code lines in Digital Asset's Daml sources (Holding.daml:57, Holding.daml:247, TokenApiUtilsV2.daml:116, HoldingV1.daml:16-19). A line number drifts with any upstream edit, and nothing detects the drift. Please cite a symbol name plus a pinned package version instead, in every new comment.
Keep the caller contract in the doc comment and move the proof out. The account_label comment had grown to 27 lines across four edits and mixed what a caller must know with version-pinned evidence for it. It now states the contract and points at issue #51, which carries the Daml proof, the roadmap and the three call sites. Drop the mainnet census. The count was stale a day after measurement and a reader cannot re-check it. Two version ranges also disagreed, 0.3.1 against 0.3.2; both now say 0.3.2. Stop citing Digital Asset's repository by line number. Six such citations across two files named a line in another organization's source, which any upstream edit invalidates silently and without a version pin. Each now names the symbol instead. The reviewer noted the drift had already started, and they were right: the comment said Holding.daml:43 while the PR body said :42, because I re-measured one against 0.3.2 and not the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pilot asked to go past the review comments: trim to the bare minimum and cite no Daml source and no issue. The comments had grown through six edits in one day, each adding evidence for a claim the last one made. What a caller needs is short. The instrument admin is the payload's registrar. A ticker is unique per admin, so compare a whole InstrumentId. A label is not an account, and comparing it to an Account.id skips a provider check. Everything else was proof, not contract: version ranges, template and interface-view names, the ensure clause, a mainnet census, and a pointer to the provider issue. None of it helps a caller and all of it dates. It survives in the PR body, the issue and the design doc, where a reader who wants evidence can find it and where staleness is visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pilot asked for the same trim everywhere, not only on the two files the review touched. Ten doc comments across six files cited DA's Daml source by module path or by line. Every one stated a fact the comment already made in its own words, then named a file to prove it. The fact stays and the citation goes: the registry compares accounts whole, it accepts exactly [receiver], each metadata key is written only when its value is present. A caller needs the behaviour, not its provenance. The allocation types lose a "DAML Splice.Api.Token.X" prefix that restated the Rust type name in another language. What each type is for survives. No behaviour changes and no signature changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What this PR does
Old behavior
A
Holdingcontract on the ledger carries eight fields.token::holding::Holdingread three of them — the amount, the owner and the ticker — and discarded the
other five. It also kept the contract id, which comes from the ledger event
rather than from the payload.
Two of the discarded fields matter.
registrarnames the party that administersthe instrument.
labelnames the account the holding sits in.A caller cannot tell two registrars' instruments apart without
registrar. Anyregistrar can issue a token called
CBTC. On devnet one party already holdslegacy
CBTCV0RC8holdings beside real ones. A caller that matched on the tickeralone selected holdings that the registry then rejected, with
400 Given holdings are invalid.Nothing could filter holdings by account without
label, because the account aholding sits in is exactly what that field records.
Separately,
InstrumentIdlived incommon::transfer, where it had sat sincethe initial commit because
Transferwas the first type that needed it.New behavior
Holdingnow keeps five of the eight fields instead of three. It readsregistrarandlabelas well.The parser puts
registrarintoinstrument_id. That field used to be a plainstring holding the ticker, such as
"CBTC". It is now a pair: the ticker, andthe party that issued it. This library calls that party the
admin, and theDaml template calls the same party the
registrar. They are one party undertwo names. That is why the parser reads one name and writes the other.
The parser puts
labelinto a new field calledaccount_label.So a caller can now answer two questions it could not answer before. Who issued
this holding? Which account does it sit in?
A caller that compares the ticker alone no longer compiles. That is deliberate.
The ticker does not identify an instrument on its own, so the old comparison
accepted another registrar's
CBTCwithout saying so.Separately,
InstrumentIdnow lives incommon::instrumentrather thancommon::transfer.Verification
cargo test --workspace— 192 tests ran, zero failures.holding.rshad notests at all and now carries 19, and every one of those 19 runs. They cover
the five parsed fields, all eight error messages, and the lock check.
The suite spans 18 binaries, but nine of those hold no test, and another 39
tests across the workspace carry
#[ignore].cargo clippy --workspace --all-targets -- -D warnings— clean, on a fresh--target-dir. That run took 22 seconds, so it is a real lint pass rather thana cache hit.
cargo fmt --check— clean.The ignored integration suite — 27 of 27 against devnet, in 655 seconds:
registry2 andtoken25, run withcargo test -p registry -p token -- --ignored --test-threads=1 integration_.These tests read real
Holdingpayloads off a live ledger and parse themthrough the widened struct, so the JSON field names are measured rather than
inferred.
The run covers commit
8be135e. Every commit since changes doc comments andnothing else, so none of them can move a ledger result:
git diff 8be135e..HEADshows only comment lines.Two of the 27 carry most of the weight:
client::integration_check_and_consolidate,in both its V1 and V2 forms. They are the only tests that exercise the parser
on payloads the ledger actually sends.
consolidate_utxosis the only production path that builds aHolding, and it takes its contracts from an interface-filtered ACS query. Ifthat query returned no
createArgument, every parse would fail. The testguarantees at least two UTXOs and then asserts
consolidated,utxos_after == 1and a preserved balance, so reaching those assertions proves real payloadsparsed through the new required reads.
What this does not cover. This repository has no CI. There is no
.githubdirectory, so no automated gate runs on this PR and
gh pr checksreportsnothing. Every number above comes from a local run on one machine. Issue #35
tracks that, and I have added this PR to it as a worked example.
The 19 unit tests on their own do not prove the JSON field names are right,
because they read a hand-written fixture. The devnet run above is what closes
that, and the fixture also matches the Daml template, which I read from source.
The devnet run proves parsing, not discrimination. Every holding it met was
CBTCunder one admin, so it never met two registrars issuing the same ticker,and it never met a non-empty
label. It shows the parser reads what the ledgeractually sends. That two different registrars now produce two different
InstrumentIdvalues is shown by the unit tests alone —reads_the_id_out_of_the_instrument_objectand
the_admin_comes_from_registrar_not_from_instrument_sourceare the two thatcarry it.
The
cbtc-libdevnet suite has not run against this change. It runs oncbtc-libPR 66, which repins to this release.The change that enables it
Holding.instrument_idchanges type fromStringtocommon::instrument::InstrumentId, which carries an admin beside the ticker.Holdinggainsaccount_label: String, read from the payload'slabel.registrarfor the admin.InstrumentIdmoves fromcommon::transfertocommon::instrument, andderives
PartialEq,EqandHash.Caveats
This breaks every consumer that compares a ticker, and every consumer that
imports
InstrumentId.canton-libreleases it as0.8.0for that reason.cbtc-libhas five ticker-comparison sites plus two doc comments, andcbtc-libPR 66 fixes them in the same repin.The migration has a wrong answer that compiles. Writing
h.instrument_id.id == "CBTC"builds fine and reintroduces the exact bug thisremoves. Compare the whole
InstrumentId. The changelog says so too.account_labelholds a label, not an account, and that is a deliberate beton the registry changing. A Token Standard
Accountisowner,providerand
id. The utility registry populates only two:holdingV1Metadatabuildsevery account with
provider = Noneas a literal, inholdingV1Metadata(registry-holding 0.3.2). Soownerplus this label determines the accounttoday, and a single
Stringis enough.Digital Asset intends to use the provider in a later iteration, and the
utility implementation of Token Standard V2 is not final. When that lands, a
label stops determining an account, and three construction sites in this
library change: this field,
TokenClient::account_for, and the batch CSVreceiver. #51 tracks
it, and explains why we cannot build the fix before DA ships theirs.
This is the reason the field is not called
account_id. A field named for thelabel it holds keeps its meaning when the account model grows; a field named
account_idwould have to change meaning.The field has already paid for itself, on devnet, the day it landed. A
reviewer of
cbtc-libPR 66 found that its V2 integration run burned alabelled holding while
check_balanceread the unlabelled account, so thebalance never moved and step 18 failed with "Balance did not decrease after
withdrawal".
list_holdingsfilters by instrument and not by account, andregistry-holding does write labels, so this is reachable rather than
theoretical. The fix selects unlabelled holdings for the burn — expressible
only because this field exists. Filtering holdings by instrument alone can
still pick a holding from an account you did not mean to touch.
One unparseable holding will fail a whole call once a consumer filters on
these fields. That behaviour lives in
cbtc-lib'slist_holdings, not in thisdiff, and the design records it as accepted there.
Re-date the changelog at merge if it slips. The
0.8.0heading reads2026-09-14, following this repo's convention of dating a release the day it
ships.
Details
Three things the diff does not show
instrument_idkeeps its name and changes its meaning. The type movesfrom
StringtoInstrumentId.Stringheld the ticker alone, andInstrumentIdholds theadmin and the ticker. No rename marks the change, so read every use of the
field rather than scanning for renamed ones.
instrument.admintoinstrument.source. TheDaml type
InstrumentIdentifierdeclaressource,idandscheme, and noadmin. An earlier branch's fixture taught a field name that does not exist.payload.registrarand writesinstrument_id.admin. Bothnames are correct, and they denote the same party. The template's V1 interface view maps them
the same way. The doc comment on
InstrumentId.adminrecords the mapping, and it names the third spelling too:InstrumentIdentifier.source.Why
InstrumentIdmovedIt is not a transfer type. Nineteen files across
token,commonandregistryreach for it, and allocations, splits, consolidations and holdings are not
transfers. The Token Standard itself declares
InstrumentIdinSplice.Api.Token.HoldingV1, so the old location had this repo's own doc commentpointing at a Holding module while the file sat in a transfer one.
The move is import paths only. Fields and JSON encoding do not change. There is
no compatibility re-export, because this release already breaks
Holdingand alingering
pub usewould preserve the wrong mental model for another release.Why
account_labelis not calledaccount_idIt holds the template's
labelverbatim, and a label is not an account. A V2account is
owner,providerandidtogether. A caller who writesh.account_label == account.idis visibly comparing a label to an id, and isskipping the provider check that
token::active_contracts::matches_accountperforms.
Through registry-holding 0.3.2 the account carries no provider, so
ownerplusthe label determines it.
holdingV1Metadatabuilds the account withprovider = Noneas a literal, so the payload's provider is never read into it.The template's
ensureclause tests the same property throughisRegistryAccount, which checksisNone account.provider, and that cannot failwhile the value it tests is a constant. All in registry-holding 0.3.2.
That is a fact about today's registry, not about the standard. The Token
Standard's
Accounthas a provider, and Digital Asset has said it intends to useit in a later iteration. When it does, a label stops determining an account on
its own. Naming the field for what it holds means that day changes what callers
build rather than what the field means — which is the main reason not to call it
account_id.Do not read the payload's own top-level
providerfield as the account'sprovider. That party is an observer on the template. The parser ignores it
deliberately, and a reader who wired it into the account would build the wrong
one.
Why
labelparses as requiredEvery
registry-holdingversion from 0.0.1 to 0.3.2 declareslabelas arequired
Text, 0.3.2 being the current one, shipped in utility 0.14.4. I readthat from Digital Asset's source.
A required read separates two cases that an optional read merges. An absent field
means the contract is not a registry
Holdingat all. An empty string means areal holding in an unlabelled account. The tests cover both.
Why the parser reads
registrarrather thaninstrument.sourceBoth fields hold the same party. The template's
ensureclause callshasValidInstrumentIdentifier, which forces the two to agree, so a check thatthey match could never fail. The parser reads
registrar, because that is thefield the interface view reads.
The parser also ignores the payload's top-level
provider.holdingV1Metadatabuilds the account with
provider = None, so the payload'sprovideris not theaccount's provider.
Why there is no
holding::v2moduleThis type parses
createArgument, which is the concrete template payload. Thatpayload is identical under Token Standard V1 and V2. The V1 and V2 split in the
other eight modules exists because registry routes and choice arguments differ,
and holding parsing touches neither.
What the move did to the rest of the diff
consolidate.rsis the only non-test consumer ofHolding, and its logic needsno change, because it reads
.amountand.contract_idonly. It does appear inthe diff, with 11 insertions and 11 deletions, and every one of them is an
InstrumentIdimport path. The same is true of most of the 24 changed files.Notes
0.8.0, which rewrites eightversionlines inCargo.lock. Eight of the nine workspace members inherit it;crates/ledgrpcis
canton-proto-rsand pins its own.fixture corrected as described above, beside 5 new ones.
Design:
cip-112docs/specs/2026-09-11-canton-lib-holding-struct-design.md·Consumer:
cbtc-libPR 66 · CI gap: #35 ·Closes the parser half of
DLC-link/cbtc-lib#74🤖 Generated with Claude Code