Skip to content

Show Daml map contents in the audit trail - #467

Merged
schronck merged 10 commits into
mainfrom
fix/audit-trail-map-contents
Sep 22, 2026
Merged

schronck merged 10 commits into
mainfrom
fix/audit-trail-map-contents

Conversation

@gyorgybalazsi

@gyorgybalazsi gyorgybalazsi commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

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 a GovernanceRules contract 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 members reads the party ids.

Here is the same contract on devnet, before and after. It is beth-network's GovernanceRules, created 9/4/2026, update id 1220d5302b65a3b7c538e2020a5288198cb89140510e25c7d2f48b901ba79d1634fa.

Before — dec-party-manager-1-devnet, running v1.11.0:

decman-460-before-unsupported-map

members and additionalProposers each show a map node containing only _unsupported: "map".

After — the same contract on this branch:

decman-460-after-members-list

members reads as four party ids and additionalProposers as one. The map wrapper is gone.

A map that carries values stays an object and shows each key with its value. Splice's Metadata.values is a TextMap Text:

values: {} 2 keys
  splice.lfdecentralizedtrust.org/reason: "quarterly rotation"
  splice.lfdecentralizedtrust.org/tx-kind: "transfer"

A map whose keys are not string-like cannot become a JSON object, so it renders as explicit pairs:

0: {} 2 keys
  key: 1
  value: "one"
1: {} 2 keys
  key: 2
  value: "two"

Which tab shows the member set

The panel opens on the Governance tab. That tab drops create events, and a created GovernanceRules contract is the only entry carrying members. 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's details. 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/state already 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 test passes: 1010 passed, 0 failed, 3 ignored. The three are the e2e suite, which needs a live network. Measured at f6f9c35, after the rebase onto main.
  • cargo fmt -- --check and cargo clippy --all-targets --all-features --no-deps -- -D warnings are clean at the same commit.
  • Eighteen new tests cover the conversion and the cache write. Seventeen are plain unit tests. One is a #[sqlx::test] against a migrated test database, which proves a re-save overwrites the cached row.
  • The conversion tests cover a TextMap, a GenMap keyed by party, by text, by contract id and by integer, an empty GenMap, a set of parties, a set of non-party keys, a TextMap of units, a GenMap and a TextMap each holding one real value among Units, the Set Party shape Audit trail hides map contents: GovernanceRules members render as _unsupported #460 reports, an empty set wrapper, a foreign record declaring its own map field, a map field with no record_id, a record carrying more than a map field, and a single map field holding no map.
  • I watched every test fail first.
  • Checked against live devnet data. I ran this branch against canton-node-1 and read beth-network's audit trail through the API. Across 367 entries, _unsupported appears zero times. The screenshots above are that same run in the UI.
  • Not covered: the rendering was checked on one party. No consumer reads details structurally, so dropping the DA.Set wrapper breaks nothing I can find.

The change that enables it

  • text_map_to_json converts a TextMap to 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_json converts a GenMap three ways. A set becomes a JSON array of its keys. Any other map whose keys are all Text, Party or ContractId becomes a JSON object. Everything else becomes an array of {"key": …, "value": …} pairs.
  • is_set decides the first case. It returns true when every value is Unit, so a set of any element type renders as an array. One non-Unit value makes it a map again.
  • The rule reads the map, not the Daml type behind it, so a TextMap Unit is a set as well. An empty map keeps the object form, because nothing marks it as a set.
  • record_to_json_inner drops the DA.Set record wrapper. set_wrapper_map identifies that wrapper by record_id alone — module DA.Set.Types, entity Set. A record without that identifier keeps its label. The audit read always asks for verbose events, so the identifier is always present.
  • An empty set renders as []. The wrapper is itself the proof that the map is a set, which is_set cannot see from the map alone.
  • save_chain_audit_cache writes with INSERT OR REPLACE instead of INSERT OR IGNORE. The table already declares (party_id, offset, contract_id, event_type) as its primary key, so this needs no migration.

Caveats

  • This output is not the canonical Daml-LF JSON encoding. The canonical encoding always renders a GenMap as 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 on value_to_json records the deviation and the reason. It also lists four smaller differences that predate this PR and remain: Unit becomes null rather than {}, a variant is tagged _variant rather than tag, Date and Timestamp stay raw proto integers, and a nested Optional loses the distinction between None and Some None. Fixing those four is separate work.
  • serde_json::Map is a BTreeMap here, so object keys come out sorted rather than in ledger order.
  • The output shape differs by key type. A consumer of GET /governance/chain-audit must handle an array, an object, and a pair array.
  • A row cached before this deploy still holds _unsupported until 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. The TextMap and GenMap match arms, five new helpers (is_unit, text_map_to_json, is_set, gen_map_key, gen_map_to_json), the DA.Set unwrap in record_to_json_inner, the cache write mode, a doc comment on value_to_json, and eighteen tests.

Review

/code-review ran 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 own map field lost that label under AuditScope::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.

  • The cache write mode. save_chain_audit_cache now uses INSERT OR REPLACE. A new #[sqlx::test] saves one entry twice and asserts the second write wins. It fails on INSERT OR IGNORE with the stale details, which reproduces Cached audit entries keep the _unsupported map marker after the fix #472's defect directly.
  • A test that guarded nothing. a_foreign_record_with_a_map_field_keeps_its_label built a TextMap, and set_wrapper_map rejects a non-GenMap before it reads the record id. The test passed with the id check deleted. It now builds a GenMap of units, so only the id check can reject it. I confirmed it fails without the check.
  • The record_id fallback. set_wrapper_map no 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 foreign map record lose its label.
  • Contract-id keys. gen_map_key now accepts ContractId. A contract id reaches the ledger API as a string, exactly as Text and Party do, 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 create events, and a GovernanceRules create is the only entry carrying members. The tab does report every membership change, and /governance/state serves the current set, so the issue closes as not planned. The section above states the reasoning.

Closes #460

🤖 Generated with Claude Code

@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

Status, and what a reviewer should weigh

Posting 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, crates/decman/src/server/chain_audit.rs. /code-review ran at high effort on the current diff; two of its three findings are fixed in c128bf9, each with a failing test first. The third is deferred, and it is the one point below that needs a conscious decision rather than a nod.

Three things to weigh

1. The output deliberately is not canonical Daml-LF JSON. The canonical encoding renders every GenMap as a list of [key, value] pairs. This PR renders a set as an array of its keys and a string-keyed map as an object, because both read better in the two viewers — the React tree and the CLI popup. The doc comment on value_to_json states the deviation and the reason. This is the main judgment call in the diff and it deserves an opinion.

2. Merging does not fix every existing install. A governance-scope row cached before this deploy keeps its _unsupported text. The cache is served in preference to the ledger and written with INSERT OR IGNORE, so Refresh shows the corrected value once and the stale row comes back on the next page load. The path is narrow — it needs a directly-created proposal template carrying a map, such as RegistrarDelegationProposal.operators — and I could not find an affected row on devnet. #472 tracks it, and that issue opens with the cheap check that would settle whether it is worth building.

I carried a purge migration in this PR for a while and removed it in 8a567a0. My original justification measured wrong: I claimed every cached row held the marker, which is false.

3. The members field is still hard to reach. #471 is separate and unfixed. The Governance scope drops create events, and a created GovernanceRules contract is the only entry carrying members. So after this merges, an auditor must switch to All activity and page back — in the screenshots above, six pages — to see the member set. That is why both screenshots use All activity.

Verification

Checked against live devnet data, not only unit tests. I ran this branch against canton-node-1, read beth-network's audit trail through the API, and found _unsupported zero times across 367 entries. The screenshots are that same run in the UI, on the contract created 9/4/2026, update id 1220d5302b65a3b7c538e2020a5288198cb89140510e25c7d2f48b901ba79d1634fa.

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 schronck left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread crates/decman/src/server/chain_audit.rs Outdated
Comment thread crates/decman/src/server/chain_audit.rs Outdated
Comment thread crates/decman/src/server/chain_audit.rs Outdated

Copilot AI 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.

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.Set records 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.

gyorgybalazsi and others added 10 commits September 22, 2026 16:47
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>
@gyorgybalazsi
gyorgybalazsi force-pushed the fix/audit-trail-map-contents branch from c128bf9 to f6f9c35 Compare September 22, 2026 14:50
@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

@schronck all four points are addressed. The branch is rebased onto main and force-pushed.

1. INSERT OR REPLACE — taken, in f6f9c35

You are right that closing #472 only held until this merges. save_chain_audit_cache now writes with INSERT OR REPLACE. The table already declares (party_id, offset, contract_id, event_type) as its primary key, so it needs no migration.

I wrote the test before the change. saving_an_entry_twice_overwrites_the_cached_row is a #[sqlx::test] against a migrated database. It saves one entry holding {"_unsupported":"map"}, saves it again holding the corrected value, then reads details back. On INSERT OR IGNORE it fails with the stale text, which reproduces #472 directly rather than describing it.

This makes one Refresh repair a stale row for good. #472 stays closed for the purge migration, which is the half that would repair a row without a Refresh.

2. The default tab — written into the body

Added under Which tab shows the member set, with your sentence and its evidence. I checked the Daml before writing it down: GovernanceRules_ConfirmGovernanceAction and GovernanceRules_ExecuteGovernanceAction both take action : GovernanceSelfAction, at Rules.daml:281 and :302. The trail writes that argument into the row details, so the Governance tab does name every membership change.

The body now separates the two claims. The tab reports each change. The tab cannot show the member set at rest, and /governance/state serves that instead.

3. Rebase — done

The branch was 3 behind. It is now 0 behind and 10 ahead of main.

The rebase was not clean, though not because of a conflict. #468 added backend DTO fields, and frontend/src/types.generated.ts is gitignored, so cargo test failed in the frontend build with TS2345 on PeerErrorKind. just gen-types fixed it. The build script says exactly this in its panic message, which saved me the diagnosis.

4. The test count — you counted right

I count 18 new test functions: 17 #[test] plus the 1 #[sqlx::test] above. Derived with git diff origin/main...HEAD -- crates/decman/src/server/chain_audit.rs piped through grep -c for each attribute. Your 16 matched the branch as you read it; two of the three commits since add one test each.

Both wrong numbers are gone from the body. While fixing them I found the list also claimed a test for an empty TextMap, and no such test exists. The list now matches the function names.

Verification at f6f9c35

  • cargo test — 1010 passed, 0 failed, 3 ignored. The three are the e2e suite.
  • cargo fmt -- --check — clean.
  • cargo clippy --all-targets --all-features --no-deps -- -D warnings — clean.

I have not re-run the devnet check since the rebase. The 367-entry result in the body predates it, and nothing in these three commits changes the conversion output for the shapes that read produced.

@schronck
schronck merged commit 74a2735 into main Sep 22, 2026
10 checks passed
@schronck
schronck deleted the fix/audit-trail-map-contents branch September 22, 2026 15:31
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.

Audit trail hides map contents: GovernanceRules members render as _unsupported

3 participants