Show Daml map contents in the audit trail - #467
Conversation
Status, and what a reviewer should weighPosting this so the state of the work is on the PR rather than in a session. This PR is ready for human review. It is one file, Three things to weigh1. The output deliberately is not canonical Daml-LF JSON. The canonical encoding renders every 2. Merging does not fix every existing install. A governance-scope row cached before this deploy keeps its I carried a purge migration in this PR for a while and removed it in 3. The VerificationChecked against live devnet data, not only unit tests. I ran this branch against Not covered: one party, one participant. Three of the seventeen tests passed on their first run, so they guard against regression rather than proving current behaviour. The rest I watched fail first. Companion work from the same investigation
|
schronck
left a comment
There was a problem hiding this comment.
Read the whole diff. The conversion logic is right and the tests are thorough. Two asks before merge, then smaller notes.
1. Take INSERT OR REPLACE into this PR.
Your own writeup on #472 calls it the correct rule and a safe drop-in, and it is one word at chain_audit.rs:769. You closed #472 because no row is stale today. That holds only until this merges. After the deploy, a node that already cached a RegistrarDelegationProposal.operators row keeps serving _unsupported, and no button in the UI re-derives a row past the first page. The purge migration is the debatable half. The write mode is not.
2. The default tab still hides the member set.
The panel opens on governance (GovernanceAuditTrail.tsx:223), which drops create. Both screenshots use All activity. I read your close on #471 and the argument holds: Confirm and Execute both carry action : GovernanceSelfAction, so every membership change is visible in the Governance scope. Put that sentence in the PR body. #460 asked to see members, and a reader who opens the default tab after this ships still does not see them.
Smaller
- Rebase. The branch is 3 behind
main. Both new commits touch other files, so it is clean. - The body says "fourteen tests" under Details and "Seventeen" under Verification. I count 16 new test functions.
Nothing here touches the conversion itself. It reads well, and the doc comment on value_to_json is the right place for the deviation.
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The conversion logic is well-scoped, documented, and thoroughly covered by relevant unit tests.
Review effort: Balanced
Findings: None
What changed in this PR
Enables audit-trail rendering of Daml map contents instead of unsupported markers.
Changes:
- Converts maps into objects, set-like arrays, or key/value pairs.
- Unwraps
DA.Setrecords for clearer display. - Adds comprehensive conversion tests and encoding documentation.
| File | Description |
|---|---|
crates/decman/src/server/chain_audit.rs |
Implements and tests map/set JSON rendering. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The audit trail rendered every Daml map as {"_unsupported":"map"} and
dropped its entries. A reviewer could not see which parties belong to a
governance committee. `members` is a `Set Party`, and a `Set` reaches the
ledger API as a record wrapping a `GenMap Party Unit`.
`value_to_json` now emits a JSON object for a `TextMap`, and for a
`GenMap` whose keys are all `Text` or `Party`. Any other `GenMap` becomes
an array of explicit key/value pairs. This is not the canonical Daml-LF
JSON encoding, which always renders a `GenMap` as a list of pairs. The
doc comment on `value_to_json` names that difference and the reason.
Cached rows still hold the old marker. The cache is written with INSERT
OR IGNORE and is read in preference to the ledger, so a fixed build would
never replace them. Migration 000020 deletes the affected rows, and the
next read re-derives them from the ledger.
Closes #460
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Daml has no set type of its own. `DA.Set` is a record wrapping a `GenMap a Unit`, so a set reached the audit trail as a map whose every value is `Unit`. Rendering it as a JSON object gave the reviewer one party per line followed by a column of nulls that carry no information. A `GenMap` whose values are all `Unit` now becomes a JSON array of its keys. The rule reads the values, not the keys, so a set of any element type renders the same way. One non-`Unit` value is enough to make it a map again. An empty map keeps the object form, because nothing marks it as a set. A `TextMap` keeps it too, because `DA.Set` never compiles to one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The set rule keyed on the Daml type that produced the map rather than on the map itself. A `TextMap Unit` carries nothing in its values, exactly as a `GenMap a Unit` does, so it reads better as an array of its keys. `is_unit` now backs both map kinds, and `text_map_to_json` mirrors `gen_map_to_json`. An empty `TextMap` still keeps the object form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The purge deleted only the rows holding the map marker. The cache read path cannot see the hole that leaves. `cached_chain_audit_page` answers a page from whatever rows it finds and reaches the ledger only when it finds none, so a surviving sibling in the same offset group keeps that page cached. The deleted entry would then never come back. The purge now takes every row of an affected party, so the party's cache misses and refetches from the ledger. `000010_purge_chain_audit_noise` could delete single rows safely, because the governance scope filters those rows out anyway. These rows must return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A set reached the audit trail as a record whose one field, `map`, held the members. A reviewer had to open two nodes to read one list. `record_to_json_inner` now renders that record as its map alone, so `members` is a bare array. The wrapper is recognised by shape: one field, named `map`, holding a map. No Daml type in this repo declares a field named `map`, so `DA.Set` is the only producer of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #460 asks for one thing: render the contents of a Daml map. The cache purge went beyond that, and the measurement behind it was wrong. The cache is written only for the Governance scope, and that scope drops `create` events. A `GovernanceRules` create is therefore never cached, so the `members` field this PR fixes cannot reach a cached row at all. A marked row needs a narrower path: a directly-created proposal template carrying a map, such as `RegistrarDelegationProposal.operators`. That path is real but rare, and it deserves its own issue and its own review rather than a paragraph in this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wrapper check matched on the field label alone. Under
`AuditScope::All` the trail renders third-party templates, and one of
those may declare its own field named `map`. That field lost its label
and rendered as a bare object, leaving an auditor unable to tell which
field they were reading.
The audit read asks for verbose events, so `record_id` is present and
names the type. The check now uses it, and falls back to the label only
when a producer omits it. The `TextMap` arm goes with it: `DA.Set` wraps
a `Map`, which arrives as a bare `GenMap`, so that arm matched nothing
real and only widened the false-positive surface.
An empty set now renders as `[]` rather than `{}`. The wrapper is itself
the proof that the map is a set, which `is_set` cannot see. Without this
the same field was an array when populated and an object when empty.
Both found by review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A verbose read always carries `record_id`, and the audit read always asks for verbose events. The label fallback therefore never fired, and its only live effect was to let a foreign single-field `map` record lose its label. `a_foreign_record_with_a_map_field_keeps_its_label` did not guard the id check. `set_wrapper_map` rejects a non-`GenMap` value before it reads the id, and the test built a `TextMap`, so it passed with the check deleted. It now builds a `GenMap` of units, which only the id check can reject. Reported by schronck in review of #467. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `ContractId` reaches the ledger API as a string, exactly as `Text` and `Party` do. A map keyed by one now reads as a JSON object rather than as an array of key/value pairs, so it matches every other string-like key. Raised by schronck in review of #467. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`save_chain_audit_cache` wrote with `INSERT OR IGNORE`, so it kept the first row for a given (party, offset, contract, event) forever. A row cached by an older build therefore outlived any fix that changed how the event renders. The refresh path re-reads the ledger and re-saves, but the write was dropped, so the stale text came back on the next page load. `INSERT OR REPLACE` makes that refresh repair the row for good. It needs no migration, because the table already declares the four columns as its primary key. Reported by schronck in review of #467. Measured in #472. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c128bf9 to
f6f9c35
Compare
|
@schronck all four points are addressed. The branch is rebased onto 1.
|
What this PR does
Old behavior
The audit trail hid the contents of every Daml map. It printed
_unsupported: "map"in place of the entries. A reviewer opening aGovernanceRulescontract saw the threshold but not the member set. The member set and the threshold are the two facts an auditor checks, so half the check was impossible.New behavior
The audit trail shows each key and each value. A reviewer expanding
membersreads the party ids.Here is the same contract on devnet, before and after. It is
beth-network'sGovernanceRules, created 9/4/2026, update id1220d5302b65a3b7c538e2020a5288198cb89140510e25c7d2f48b901ba79d1634fa.Before —
dec-party-manager-1-devnet, running v1.11.0:membersandadditionalProposerseach show amapnode containing only_unsupported: "map".After — the same contract on this branch:
membersreads as four party ids andadditionalProposersas one. Themapwrapper is gone.A map that carries values stays an object and shows each key with its value. Splice's
Metadata.valuesis aTextMap Text:A map whose keys are not string-like cannot become a JSON object, so it renders as explicit pairs:
Which tab shows the member set
The panel opens on the Governance tab. That tab drops
createevents, and a createdGovernanceRulescontract is the only entry carryingmembers. Both screenshots above therefore use All activity.The Governance tab still reports every membership change. A confirm row and an execute row both carry
action : GovernanceSelfAction, and the trail writes that choice argument into the row'sdetails. So the tab names the member added or removed, and the resulting threshold.What the tab cannot show is the member set at rest.
/governance/statealready serves that set from the active rules contract, and the UI reads it for the "Member to remove" dropdown. #471 records this and closes as not planned.Verification
cargo testpasses: 1010 passed, 0 failed, 3 ignored. The three are the e2e suite, which needs a live network. Measured atf6f9c35, after the rebase ontomain.cargo fmt -- --checkandcargo clippy --all-targets --all-features --no-deps -- -D warningsare clean at the same commit.#[sqlx::test]against a migrated test database, which proves a re-save overwrites the cached row.TextMap, aGenMapkeyed by party, by text, by contract id and by integer, an emptyGenMap, a set of parties, a set of non-party keys, aTextMapof units, aGenMapand aTextMapeach holding one real value amongUnits, theSet Partyshape Audit trail hides map contents: GovernanceRules members render as _unsupported #460 reports, an empty set wrapper, a foreign record declaring its ownmapfield, amapfield with norecord_id, a record carrying more than amapfield, and a singlemapfield holding no map.canton-node-1and readbeth-network's audit trail through the API. Across 367 entries,_unsupportedappears zero times. The screenshots above are that same run in the UI.detailsstructurally, so dropping theDA.Setwrapper breaks nothing I can find.The change that enables it
text_map_to_jsonconverts aTextMapto an array of its keys when it is a set, and to a JSON object otherwise. The keys are already strings, so it needs no key check.gen_map_to_jsonconverts aGenMapthree ways. A set becomes a JSON array of its keys. Any other map whose keys are allText,PartyorContractIdbecomes a JSON object. Everything else becomes an array of{"key": …, "value": …}pairs.is_setdecides the first case. It returns true when every value isUnit, so a set of any element type renders as an array. One non-Unitvalue makes it a map again.TextMap Unitis a set as well. An empty map keeps the object form, because nothing marks it as a set.record_to_json_innerdrops theDA.Setrecord wrapper.set_wrapper_mapidentifies that wrapper byrecord_idalone — moduleDA.Set.Types, entitySet. A record without that identifier keeps its label. The audit read always asks for verbose events, so the identifier is always present.[]. The wrapper is itself the proof that the map is a set, whichis_setcannot see from the map alone.save_chain_audit_cachewrites withINSERT OR REPLACEinstead ofINSERT OR IGNORE. The table already declares(party_id, offset, contract_id, event_type)as its primary key, so this needs no migration.Caveats
GenMapas a list of[key, value]pairs, because a GenMap key is any Daml value while a JSON object key must be a string. This PR deviates twice, because both forms read better in the two viewers. A set becomes an array of its keys. Any other map with string-like keys becomes an object. The doc comment onvalue_to_jsonrecords the deviation and the reason. It also lists four smaller differences that predate this PR and remain:Unitbecomesnullrather than{}, a variant is tagged_variantrather thantag,DateandTimestampstay raw proto integers, and a nestedOptionalloses the distinction betweenNoneandSome None. Fixing those four is separate work.serde_json::Mapis aBTreeMaphere, so object keys come out sorted rather than in ledger order.GET /governance/chain-auditmust handle an array, an object, and a pair array._unsupporteduntil something re-reads it. The new write mode makes one Refresh repair that row for good. Cached audit entries keep the _unsupported map marker after the fix #472 tracks the purge migration that would repair it without a Refresh, and it stays closed.Details
What changed
crates/decman/src/server/chain_audit.rs, and nothing else. TheTextMapandGenMapmatch arms, five new helpers (is_unit,text_map_to_json,is_set,gen_map_key,gen_map_to_json), theDA.Setunwrap inrecord_to_json_inner, the cache write mode, a doc comment onvalue_to_json, and eighteen tests.Review
/code-reviewran at high effort on this diff and raised three findings, all low. Two are fixed. The wrapper check matched on the field label alone, so a third-party template declaring its ownmapfield lost that label underAuditScope::All. And an empty set rendered as{}while a populated one rendered as[], so the same field changed JSON type between events. Both came with a failing test first.@schronck then reviewed the whole diff and raised four points. All four are addressed here.
save_chain_audit_cachenow usesINSERT OR REPLACE. A new#[sqlx::test]saves one entry twice and asserts the second write wins. It fails onINSERT OR IGNOREwith the staledetails, which reproduces Cached audit entries keep the _unsupported map marker after the fix #472's defect directly.a_foreign_record_with_a_map_field_keeps_its_labelbuilt aTextMap, andset_wrapper_maprejects a non-GenMapbefore it reads the record id. The test passed with the id check deleted. It now builds aGenMapof units, so only the id check can reject it. I confirmed it fails without the check.record_idfallback.set_wrapper_mapno longer falls back to the field label. A verbose read always carries the identifier, so the fallback never fired in production, and its only live effect was to let a foreignmaprecord lose its label.gen_map_keynow acceptsContractId. A contract id reaches the ledger API as a string, exactly asTextandPartydo, so a map keyed by one reads as an object.The branch is rebased onto
main.A problem this work surfaced, filed separately
#471 — the Governance tab does not name the member set. The tab omits
createevents, and aGovernanceRulescreate is the only entry carryingmembers. The tab does report every membership change, and/governance/stateserves the current set, so the issue closes as not planned. The section above states the reasoning.Closes #460
🤖 Generated with Claude Code