Skip to content

feat(decman-lib): extract the governance domain model and gRPC codecs - #382

Draft
hubagaspar91 wants to merge 28 commits into
mainfrom
feat/refact/decman-lib
Draft

feat(decman-lib): extract the governance domain model and gRPC codecs#382
hubagaspar91 wants to merge 28 commits into
mainfrom
feat/refact/decman-lib

Conversation

@hubagaspar91

@hubagaspar91 hubagaspar91 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR creates crates/decman-lib and moves decman's governance protocol
knowledge into it. The crate holds the typed models for every governance
action and proposal, the gRPC codecs that turn them into Ledger API values,
and the pure functions that parse governance state back out of events.
DecMan now calls the lib. The wire behavior does not change.

Three codebases implement this protocol today: decman (gRPC), the attestor
stack (JSON), and soon the token launchpad. The lib ends the duplication for
the gRPC side and gives external integrators one crate to build governance
clients with: propose, confirm, execute, expire, and cancel — for the core
actions and for custom GovernableAction templates they write themselves.
The JSON codec and the attestor stay out of scope (phase 2).

The design doc behind this PR was reviewed before implementation. Its core
decision is an asymmetric type model that mirrors the Daml:

  • The by-value action side is a closed Daml union. Closed union = Rust enum.
    ActionType moves whole and gains codec methods: to_vault_proto,
    to_self_proto, from_vault_proto, from_self_proto, validate. The
    two production panics became typed errors.
  • The proposal side is an open interface. Open interface = one struct per
    payload (29 of them) plus three capability traits (TemplateInfo,
    DamlProtoEncode, Validate) and a frozen GrpcPayload combination
    trait. Third parties implement the traits for their own templates and get
    the same builders decman uses.

Implementation details

decman_lib::framework (template-agnostic; never names a Daml template):

  • The capability traits and ValidationCtx. Validation takes the governance
    party and now_micros as parameters — the lib never reads a clock.
    TemplateInfo resolves package refs through a PackageResolver trait;
    the lib ships impls for PackageConfig and for a bare &str, so an
    integrator needs no decman config type to name their package.
  • TemplateId: a #package-name ref plus module and entity. It renders as
    pkg:Module:Entity and matches events on module+entity only, because
    Canton echoes resolved package hashes back.
  • The encode toolkit (make_party, make_record, the extraArgs
    builders, TransferValidity) and the record-reading toolkit (strict
    Result readers and lenient Option readers, plus the Set Party,
    GenMap, and RelTime extractors).
  • The event-filter builders for ACS and update reads.
  • commands_envelope (replaces the duplicated Commands literals;
    command_id is a parameter, so the lib needs no uuid dependency) and
    build_propose, which injects the governanceParty and proposer
    fields every GovernableAction template carries.

decman_lib::catalog (decman's protocol content):

  • ActionType (21 variants) with the four codec methods; decode dispatch
    lives here, so integrators parse confirmations with one call.
  • 29 proposal payload structs across five modules (core, custody, rewards,
    credential, utility). serde attributes moved byte-identically; ts-rs and
    utoipa derives sit behind typegen / openapi features.
  • Transfer and AcceptTransfer split into wire structs and wrapper encode
    structs (TransferWithContext, AcceptTransferWithContext). The wrappers
    carry the registry choice context and the validity window, so the type
    system forces the context decision; an empty-context transfer cannot be
    encoded by accident.
  • Template accessors that error with PackageNotConfigured where decman
    used to 503, and the template lists the fetch loops iterate.
  • Thirteen flow builders: confirm, execute, and expire per governance kind,
    the three confirmation cancels, and the atomic two-command proposal
    retraction.
  • The interpretation layer: parse_confirmation, parse_domain_confirmation
    (honestly action-less — the on-ledger template stores no action),
    extract_governance_state, extract_proposal_info, the dedupe and
    liveness helpers, and the orphan-rule assembly.

DecMan changes:

  • ProposalType stays in decman as the HTTP enum, reshaped to newtype
    variants that wrap the lib structs. The serde tag layout keeps the JSON
    byte-identical.
  • The read path fetches with decman's client, parses with the lib, and maps
    to the HTTP DTOs. The legacy placeholder action on domain confirmations
    survives in exactly one mapping function, for wire compatibility.
  • The write handlers keep their GovernanceType match; each arm is now
    accessor → package-ref resolve → one builder call → submit.
    propose_action keeps auth, validation, the delegation-singleton guard,
    and the registry fetches; the lib builds the commands.
  • action_serializer.rs (3.6k lines) is deleted.

One edge worth knowing:

  • crates/common gained eight additive PartialEq derives on wire DTOs
    (nothing else changed there). The lib structs derive PartialEq for the
    round-trip tests and need the embedded types to match.

Related issues

None.

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Build / CI / chore

Checklist

  • cargo fmt -- --check passes
  • cargo clippy --all-targets --all-features -- -D warnings is clean
  • cargo test passes
  • Added/updated tests where appropriate
  • Updated documentation where appropriate
  • Commits follow the <type>(<scope>): <subject> convention

Notes for reviewers

How this was tested. The first commit snapshots the HTTP JSON of every
enum variant with insta, before any conversion. Every later commit keeps
those snapshots byte-identical; that is the wire-compatibility proof. The
full localnet e2e (governance_workflows_e2e) ran green six times across
the branch, and it re-runs on the head of every review round. The lib
carries 110 tests plus a doctest: codec
round-trips for both encode forms, wrong-form and unknown-constructor error
tests, exact pkg:Module:Entity strings for all 29 payloads, encode
snapshots with populated and minimal fixtures, interpretation boundary
tests (expiry at now, threshold edges, orphans never execute, both
Set Party wire shapes), and a compiling integrator doctest in the crate
rustdoc.

Where to look first. crates/decman-lib/src/lib.rs (the quick-start
doctest), catalog/action.rs (the enum move), catalog/proposals/custody.rs
(the wrapper pattern), server/queries.rs (the read-path adoption and the
one placeholder mapping), and handlers/governance.rs (the write-path
adoption).

Size. The PR is far over the usual ~400-line norm. It is a reviewed
extraction executed as 28 stepwise commits; each commit compiles, passes
the full suite, and moves one bounded slice. Reviewing commit-by-commit is
the intended path.

Review rounds so far. The first review reshaped three things on the
branch: TemplateInfo now resolves package refs through the
PackageResolver trait (the PackageConfig impl destructures without
.., so a new config field is a compile error there); the CI dependency
gate came and went, so the branch's net CI diff is empty; and the payload
projections in ProposalType are explicit matches with a guard test that
pins which variants carry a GrpcPayload. Extraction-changelog comments
are gone throughout.

Follow-ups (non-blocking). An all-None codec round-trip fixture for
ActionType; a decman unit test for the DomainAction → DTO mapping; one
manual OpenAPI diff against main; feature-gating canton-api-client in
canton-lib's common (it pulls reqwest into every consumer); phase 2
adds the JSON codec and the attestor.

The decman-lib extraction reshapes ProposalType to newtype variants
and moves ActionType into the new crate. These insta snapshots pin the
HTTP wire JSON of every variant, populated and minimal, so every
extraction commit can prove the wire shape never moves.
New workspace crate for the extracted governance domain model and
gRPC codecs. Two public modules split by one rule: framework never
names a Daml template, catalog does. The thiserror Error keeps
decman's exact validation and package-not-configured strings.
Lib-owned template id: a #package-name ref plus module and entity.
Owns the two wire conventions: Display as pkg:Module:Entity, and
event matching on module+entity only, because Canton echoes resolved
package hashes back in events.
The Daml Value constructors, the extraArgs builders, and
TransferValidity move from decman's action_serializer to
decman_lib::framework::encode, as pub. Integrators cannot implement
DamlProtoEncode without them. decman now depends on decman-lib.
One shared module now holds both record-reading shapes: the strict
Result readers and the lenient Option readers, plus the Set Party,
GenMap, and RelTime extractors. Sources: decman record.rs, the
action_serializer extract helpers, and the queries field helpers.
The EventFormat builders move whole. Integrators need them to build
the same ACS and update reads decman runs.
One capability per trait, plus the GrpcPayload combination with a
blanket impl. Validate takes a ValidationCtx: the governance party
and now_micros enter as parameters, so checks stay deterministic.
The extension test doubles as the integrator example.
commands_envelope replaces decman's seven duplicated Commands
literals; command_id is a parameter so golden tests stay
deterministic and the lib needs no uuid dep. build_propose serves any
TemplateInfo+DamlProtoEncode payload, injecting the two party fields
every GovernableAction template carries.
The payload support types move to the catalog with feature-gated
utoipa/ts-rs derives; decman re-exports them so paths and the HTTP
JSON stay identical. The protocol validators move to the framework;
validate_future_micros now takes now_micros as a parameter, and every
message string stays byte-identical inside Error::Validation.
The closed Daml unions map to a Rust enum with methods, so the enum
moves whole: both encode forms, both decode dispatches, and validate.
The two production panics become Encode errors. Integrators now parse
vault and self confirmations with one call. decman re-exports the
enum, so the HTTP JSON is unchanged by construction.
Five payloads move to catalog structs with the capability traits;
their ProposalType variants become newtype wrappers. The JSON
snapshots pin the wire shape; the ts-rs output changes shape (inline
fields become intersections) with unchanged field names and
optionality. ProvisionProviderService becomes an empty-braces struct
because serde cannot internally-tag a unit struct.
Transfer and AcceptTransfer split into wire structs (Validate +
TemplateInfo) and wrapper encode structs that carry the registry
choice context, the validity window, and the sender. The type system
now forces the context decision at the call site instead of an
empty-context default that fails on-ledger at execute.
The four rewards payloads move with their validate bodies. The
minting-delegation expiry check now reads now_micros from the
ValidationCtx instead of the clock, so the check is deterministic.
The three credential payloads move with their validate bodies and
the BillingParams encoder.
The last thirteen payloads move. Every ProposalType variant is now a
newtype over a catalog struct, every validate arm delegates through
the ValidationCtx, and every build_proposal_create_args arm is glue
that Task 23 deletes.
The rules, confirmation, and interface template ids move behind
Result accessors that error where decman used to 503. Exact-string
tests pin every payload struct's template id.
Pure per-event parses over CreatedEvent. The domain confirmation
type carries no action, because the on-ledger template stores none;
decman's HTTP placeholder stays a decman mapping concern. Task 20
switches queries.rs onto these.
The generic GovernableAction interface-view parse and the three
detail extractors move as pure functions over CreatedEvent. The
detail DTOs move to the catalog; decman re-exports them so the HTTP
JSON and generated TypeScript names stay put.
The dedupe, liveness, and orphan-rule logic leaves the fetch loops as
pure functions with time as a parameter. The semantics tests pin the
boundaries: newest-per-party, expiry at now, threshold edges, orphans
never execute, synthesis only on two complete fetches.
The governance read path now fetches with decman's client, parses
with the lib, and maps to the HTTP DTOs. The placeholder action on
domain confirmations survives only inside the one mapping function,
so the wire JSON is unchanged while the lib types tell the truth.
Thirteen pure builders cover confirm, execute, expire, and cancel
for all three governance kinds, plus the atomic proposal retraction.
Each returns a Commands ready for submit_and_wait; package-ref
resolution and submission stay with the caller.
Each write handler keeps its GovernanceType match, but every arm is
now accessor, package-ref resolve, one lib builder call, submit. The
seven duplicated Commands literals are gone.
The propose handler keeps auth, validation, the singleton guard, the
registry fetches, and submission; the lib builds the create and
self-confirm commands and extracts the created cid. The interim
build_proposal_create_args glue and the ProposalPackage enum die, and
CI gains the decman-lib forbidden-dependency gate.
GitHub runs run-blocks with bash -e, which exempts !-inverted
commands, so the gate's first line could never fail the job. The
fail-closed if-forms restore the depth-1 reqwest check, and pipefail
makes a cargo-tree failure fatal instead of a vacuous pass. The docs
that named the deleted action_serializer.rs now point at the lib.
The crate-level rustdoc gains a compiling quick-start: a custom
proposal through build_propose, plus the read side and a by-cid
confirm. The CI dependency gate widens actix-web to the actix family
and documents the tonic-transport blind spot. Two doc provenance
notes and one zero-constant cleanup ride along.
TemplateInfo now takes a PackageResolver, so external integrators
resolve package refs from their own config, a map, or a bare str —
PackageConfig demotes to one shipped convenience impl. DecMan call
sites are unchanged: the reference coerces.
The CI dependency gate goes away per review. One macro-generated
projection replaces the repeated validate arms and the grpc_payload
match in ProposalType. The what-moved-where comments go; comments now
describe what the code is, not the extraction.
The stale test-mode/wildcard comments now state the live reason the
view fallback exists. The PackageConfig resolver destructures without
`..`, so a new config field is a compile error here. The projection
macro unrolls into two explicit matches per review preference, and a
guard test pins which variants carry a GrpcPayload.
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.

1 participant