Prototype SV governance voter flow#5533
Open
ericmann wants to merge 31 commits into
Open
Conversation
Define the initial Phase 1 allowlist for governance-voter eligible actions and cover the proposed taxonomy plus represented-SV vote-slot semantics in Daml tests. Signed-off-by: Eric Mann <eric@avrofi.com>
Add explicit vote-cast role metadata so the vote record can identify the represented SV and operator signing path without changing tally semantics. Signed-off-by: Eric Mann <eric@avrofi.com>
Introduce the SV-declared governance-voter binding lifecycle without a contract key so the Phase 1 authority model can be reviewed independently of submit-path mechanics. Signed-off-by: Eric Mann <eric@avrofi.com>
Use the SV governance-voter binding to authorize alternate signing while recording the vote in the represented SV's existing vote slot. Signed-off-by: Eric Mann <eric@avrofi.com>
Keep the consolidated prototype to a single governance package version bump so intermediate stack-only DAR versions do not leak into the final review branch. Signed-off-by: Eric Mann <eric@avrofi.com>
Use represented SV parties as vote map keys and prevent governance-voter submissions from replacing prior operator votes, making the shared vote-slot semantics explicit for review. Signed-off-by: Eric Mann <eric@avrofi.com>
isegall-da
reviewed
May 13, 2026
isegall-da
reviewed
May 13, 2026
isegall-da
reviewed
May 13, 2026
State that explicit disclosure is the supported submission path for unaffiliated governance voters, keep SV-hosted relay as an optional deployment choice, and flag CIP-0103 alignment as the remaining review item before this prototype is promoted out of draft. Signed-off-by: Eric Mann <eric@avrofi.com>
isegall-da
reviewed
May 13, 2026
CIP-0103 governs the dApp/Wallet API rather than on-ledger contract shape; the cast choice is already compatible with an external-party prepareExecute flow with disclosed contracts. The remaining alignment work lives in the governance-voter dApp client, not in this PR. Signed-off-by: Eric Mann <eric@avrofi.com>
Ian-avro
reviewed
May 13, 2026
Address review feedback: drop the operator override on governance-voter eligible actions, route their vote requests through the governance-voter path, and replace clearing with rotation back to the represented SV (with onboarding default of self-voting). - Remove the ClearGovernanceVoter choice from SvGovernanceVoter; the represented SV always has a binding, and "return to operator" is RotateGovernanceVoter back to self. - Add DsoRules_RequestGovernanceVote (governance-voter controlled) and reject governance-voter eligible actions on DsoRules_RequestVote / DsoRules_CastVote. DsoRules_CastGovernanceVote no longer needs the operator-overwrite guard. - Update DsoTestUtils helpers to dispatch by action type and to auto-create self-bindings; rewrite the prototype lifecycle/cast tests for the new policy; port existing direct request/cast call sites to the appropriate path. - Refresh the bundled splice-dso-governance 0.1.25 DAR, dars.lock, and the SV governance-voter doc. Signed-off-by: Eric Mann <eric@avrofi.com>
Phase 1 does not enforce a one-active-binding-per-SV invariant: the represented SV may keep more than one SvGovernanceVoter contract alive at the same time, and any active binding can authorize a cast for the represented SV. All such casts land in the represented SV's single vote slot under the same per-SV cooldown, so additional bindings broaden the set of authorized signers without changing the one-vote-per-SV tally. - Document the multi-binding semantics in the SV governance-voter doc. - Add testGovernanceVoterMultipleBindings exercising two concurrent bindings for sv1 (delegateA, delegateB), validating that both can cast into the same represented-SV slot, that the second cast overwrites the first via the cooldown path, and that a delegate cannot cast against the other delegate's binding (signer-must-match-binding check). Signed-off-by: Eric Mann <eric@avrofi.com>
DsoRules_CastVote and DsoRules_CastGovernanceVote previously accepted votes regardless of how much time had elapsed since the request was opened. The voting period is meaningful only if both the request choices and the cast choices enforce it. Add a deadline check on both cast paths that mirrors the existing close-vote semantics: when targetEffectiveAt is set the voting period extends to the effective time (matching the documented behavior that SVs can keep voting between voteBefore and targetEffectiveAt); when it is not set the deadline is voteBefore. Add testCastDeadlineExpiry to lock down both branches. Signed-off-by: Eric Mann <eric@avrofi.com>
The operator cast path tells the caller which choice to use when the
action does not belong on that path ("use DsoRules_CastGovernanceVote").
The governance-voter cast path was missing the equivalent hint and read
as a flat statement of fact. Match the operator path's wording so the
two messages are symmetric and point the caller at the right choice.
Signed-off-by: Eric Mann <eric@avrofi.com>
DsoRules_CastVote previously overwrote the caller-supplied castBy and castByRole silently, mirroring the operator authority but masking any caller-side mismatch. DsoRules_CastGovernanceVote instead validates the caller-supplied values against the binding before overwriting; the operator path should follow the same fail-loud pattern so caller bugs surface immediately. Add two require checks on the operator cast path: castBy must equal vote.sv, and castByRole must be VCR_Operator. The silent overwrite remains for forward-compatibility with future timestamping but no longer hides incorrect callers. Add testOperatorCastAttributionGuards to exercise both guards. Signed-off-by: Eric Mann <eric@avrofi.com>
The binding template's module comment claims "there is intentionally no Clear choice," but the implicit per-signatory Archive choice still lets the represented SV unilaterally archive any of its bindings (the SV is the sole signatory). Reviewers reading the no-Clear claim shouldn't mistake it for a hard invariant. Acknowledge the escape hatch in the template's module comment and in the SV governance-voter doc, and frame it as self-harm only that is trivially recoverable via a new self-binding. Hard enforcement of "an SV always has at least one active binding" is deferred. Signed-off-by: Eric Mann <eric@avrofi.com>
The SV dApp PoC discussion landed on a single governance-voter party per represented SV: accountability is hard enough with one voting party to declare, and multi-user assignment lives at the dApp/UI layer rather than via multiple ledger bindings. This reverses the earlier prototype note that explicitly permitted multiple concurrent bindings. - Update the SV governance-voter doc to state the single-binding intent and flag that Phase 1 enforces it through the workflow (RotateGovernanceVoter exclusively) rather than at the template level. Promotion to a contract key or registry is left as an open question for Splice maintainers. - Drop testGovernanceVoterMultipleBindings, which exercised behavior the design no longer endorses. - Add an explicit "exactly one active binding for sv1" assertion at each step of testSvGovernanceVoterBindingLifecycle so the invariant is visible in the test, not just implied by the consuming choice. Signed-off-by: Eric Mann <eric@avrofi.com>
Code review on the prototype draft pointed out that the single-active-binding-per-SV invariant is workflow-only (onboarding default plus consuming RotateGovernanceVoter), not enforced by the SvGovernanceVoter template itself. If a represented SV does bare-create parallel bindings, both delegates can cast against the same represented- SV slot under last-writer-wins. This commit captures that boundary as a test so future work (contract key, single-binding registry, or onboarding-side enforcement) has a concrete behavior to compare against: - testGovernanceVoterDuplicateBindingsAmbiguity exercises sv1 bare- creating two SvGovernanceVoter contracts in parallel, has each delegate cast against the same represented-SV slot, and asserts the slot count stays at one with last-writer-wins on castBy and castByRole. No template or choice changes; the test pins existing behavior the CIP also flags as an open review question. Signed-off-by: Eric Mann <eric@avrofi.com>
DsoRules_CastGovernanceVote used fetchChecked + archive because it needs to validate that the request action is governance-voter eligible before consuming it (unlike DsoRules_CastVote, which uses fetchAndArchive after the operator-path eligibility check). The cooldown check sat after the explicit archive, which is functionally correct because the transaction aborts on failure, but is non-idiomatic: validation should precede destructive operations. Move enforceCooldown above archive requestCid in DsoRules_CastGovernanceVote so all gate checks (binding validity, role, SV membership, action eligibility, deadline, cooldown) run before the request is consumed. The operator path already follows this order via fetchAndArchive; this aligns the governance-voter path stylistically. Bump splice-dso-governance to 0.1.26 and refresh dars.lock. Signed-off-by: Eric Mann <eric@avrofi.com>
A re-review pointed out that the prototype design doc described the single-active-binding shape as preserved "by construction" through the consuming RotateGovernanceVoter lifecycle, and as "enforced through the workflow", both of which overstate what the template actually guarantees. The represented SV is the sole signatory and can bare- create additional bindings; the consuming rotation only enforces that one specific binding is consumed when a rotation happens, not that no parallel binding ever exists. Soften the wording in docs/src/sv_operator/sv_governance_voter.rst to match the language in the companion CIP draft: - The intended Phase 1 workflow has one active governance-voter party per represented SV, shaped by the consuming rotation and self- binding onboarding default, rather than preserved as a contract- level invariant. - Spell out the residual behavior: if two bindings do coexist, both delegates can cast under last-writer-wins; the tally is still one vote per represented SV, but the cast log becomes ambiguous. - Reference testGovernanceVoterDuplicateBindingsAmbiguity, which pins that behavior so any future tightening has a baseline. No Daml code or test changes; the template comments and the new test already use the workflow-intent framing. Signed-off-by: Eric Mann <eric@avrofi.com>
Bring two user-facing labels in `buildAmuletConfigChanges.ts` into line with the `no_illegal_daml_references` allow-list, which permits the phrase "Amulet to Issue" (matching the camel-case identifiers `amuletToIssuePerYear` / `AmuletToIssuePerYear` already used in the file) but not the lowercase "Amulet to issue". Purely cosmetic; no behavior change. Unblocks the pre-commit hook for follow-up commits. Signed-off-by: Eric Mann <eric@avrofi.com>
Address review comments canton-network#10 and canton-network#11 by making the DSO party the sole signatory of SvGovernanceVoter and moving the rotation flow off the template and into DsoRules: - SvGovernanceVoter: signatory dso, observer sv only (governanceVoter is no longer a ledger observer; it discovers the binding via Scan or explicit CIP-0103 disclosure, so a participant hosting that party does not need to vet the splice-dso-governance DAR). On-template RotateGovernanceVoter choice removed. - DsoRules: new SRARC_RotateGovernanceVoter action and internal DsoRules_RotateGovernanceVoter choice. Rotation runs through the standard confirmation-quorum flow, so a single SV operator cannot unilaterally rotate their own binding. Adds HasCheckedFetch SvGovernanceVoter ForDso to support the fetchAndArchive in the choice body. - DsoRules_AddSv: createPerSvPartyContracts now atomically establishes the (sv, sv) self-binding at onboarding, preserving the one-binding-per-represented-SV invariant from contract creation. - Tests rewritten against the new flow: strict ensureSelfBinding lookup, new rotateGovernanceVoterByConfirmation helper, lifecycle and cast-path tests exercise the confirmation-quorum rotation, and the SRARC taxonomy test treats SRARC_RotateGovernanceVoter as a non-governance-eligible operational action. The duplicate-bindings-ambiguity test is removed since SV bare-create is no longer authorized. - Docs: sv_governance_voter.rst rewritten to describe the DSO-signatory model, atomic onboarding self-binding, and confirmation-quorum rotation. - splice-dso-governance bumped to 0.1.27; dars.lock and the versioned DAR refreshed accordingly. Signed-off-by: Eric Mann <eric@avrofi.com>
The `sv /= dso && governanceVoter /= dso` invariant is already enforced by the only paths that create the contract: - onboarding (`DsoRules_AddSv` / `createPerSvPartyContracts`) installs a self-binding for a newly added SV party, which by construction is not the DSO party, and - rotation (`DsoRules_RotateGovernanceVoter`) explicitly requires `newGovernanceVoter /= dso`. No other contract template in this package uses an `ensure` clause for cross-field party-distinctness, so the assertion was both redundant and inconsistent with the rest of the codebase. Signed-off-by: Eric Mann <eric@avrofi.com>
Replace the SRARC_* wildcard fall-through in `isGovernanceVoterAction` with an explicit case per constructor (preserving current True/False truth values byte-for-byte). New SRARC_* additions will now fail to compile here, forcing the author to make an explicit eligibility decision rather than silently inheriting a default. The CRARC_* (AmuletRules) branch keeps its wildcard intentionally: AmuletRules actions are owned by a separate codebase area and changing their default of non-eligibility should be driven from there, not from this SV-side taxonomy. The docstring records the asymmetry. Signed-off-by: Eric Mann <eric@avrofi.com>
Two upgrade-rule violations introduced by the governance-voter work, flagged on review: 1) VoteRequest.votes had its key changed from `Map.Map Text Vote` to `Map.Map Party Vote`. Existing on-ledger VoteRequest contracts have Text (SV name) keys; changing the key type is a breaking change. Revert to Text keys. The represented SV's party is still recoverable from `vote.sv`. 2) The Vote record gained two required fields (`castBy : Party`, `castByRole : VoteCastRole`) inserted in the middle of the record. Daml upgrade rules require new fields to be optional and appended at the end so existing contracts can be lifted with `None` values. Move both fields to the end and wrap in `Optional`. Knock-on changes: - `DsoRules_CastGovernanceVote` lifts `castBy` to a top-level choice argument so it can serve as a (non-Optional) controller. The `Vote.castBy` / `Vote.castByRole` fields remain as attribution metadata: server-set to `Some _` for new votes, `None` for legacy contracts. The choice no longer validates `vote.castByRole`; the role is implicit in the choice itself and is recorded on the resulting Vote. - `DsoRules_CastVote` drops its input-validation requires for `vote.castBy` / `vote.castByRole`. Those fields were overwritten on recording so the validation was cosmetic; the controller `vote.sv` remains the real authorization. The `testOperatorCastAttributionGuards` test that exercised the dropped validations is removed. - All vote-construction sites (in DsoRules choices, DsoTestUtils, and TestGovernance) set `castBy = Some <party>` and `castByRole = Some <role>` explicitly for new votes. - All map operations on `votes` use SV names (`Text`) as keys: cast paths look up the voter's name via the `svs` map and key insertions on that name; `CloseVoteRequest` iterates `(_, vote)` and uses `vote.sv` to determine represented-SV activity. `offboardedVoters` continues to wire-encode the offboarded SV's party via `partyToText vote.sv`, preserving the existing field semantics. - Tests updated accordingly: `Map.lookup "sv1" request.votes`, `vote.castBy === Some <party>`, etc. No Scala touched: codegen consumers of `request.votes` only iterate `.values()`, and no Scala references `castBy` / `castByRole` today. splice-dso-governance bumped to 0.1.30; dars.lock and the versioned DAR refreshed accordingly. Signed-off-by: Eric Mann <eric@avrofi.com>
…me binding In the governance-voter request/cast choices, replace the bare `fetch` of the SvGovernanceVoter binding with a checked fetch against `ForOwner with dso; owner = <controller>`. The new `HasCheckedFetch SvGovernanceVoter ForOwner` instance validates both the DSO and that the caller is the binding's authoritative governance voter in a single step, so the manual `require` checks for those invariants are dropped. Also rename the local variable `binding` to `svGovernanceVoter` so it matches the template name and is unambiguous next to the `governanceVoter` party field. Signed-off-by: Eric Mann <eric@avrofi.com>
…al binding `DsoRules_RequestVote` now takes an Optional `bindingCid : ContractId SvGovernanceVoter` appended at the end and branches in the body: * `bindingCid = None` -> operator path (existing behavior). The controller `requester` is the represented SV; action must NOT be governance-voter eligible; the initial vote is recorded with VCR_Operator. * `bindingCid = Some _` -> governance-voter path. The controller `requester` is the governance voter named on the binding; the binding is checked-fetched via `ForOwner with owner = requester`; action must be governance-voter eligible; the represented SV is taken from the binding and the initial vote is recorded with VCR_GovernanceVoter. `DsoRules_RequestGovernanceVote` and its result type are removed; all Daml callers (test helper and tests) migrate to the unified choice. Docs updated to describe the optional-binding shape. Signed-off-by: Eric Mann <eric@avrofi.com>
…ding `DsoRules_CastVote` now takes two Optional fields appended at the end: * `bindingCid : Optional (ContractId SvGovernanceVoter)` — when `Some`, selects the governance-voter path. * `castBy : Optional Party` — the governance-voter party signing the cast on the governance-voter path; serves as the choice controller. Must be present iff `bindingCid` is. The controller expression is `fromOptional vote.sv castBy`, so on the operator path (both fields `None`) the controller remains `vote.sv` as before. On the governance-voter path the controller is the `castBy` party, the binding is checked-fetched via `ForOwner with owner = castBy`, the recorded vote's SV is taken from the binding (cannot drift from `vote.sv`), and the vote is attributed with `VCR_GovernanceVoter`. The two paths are otherwise unified: action-eligibility check, deadline check, per-SV cooldown, and request archival are shared. `DsoRules_CastGovernanceVote` and its result type are removed; all Daml callers (test helper, tests, docs) migrate to the unified choice. Signed-off-by: Eric Mann <eric@avrofi.com>
…e shapes The Daml-side schema changes in this PR add Optional fields to several records and choices that the Scala apps construct directly via Java codegen. This commit updates every such call site so the apps continue to compile against the new codegen output: * Vote record (now 6 fields): added trailing `Optional.empty()` for `castBy` and `castByRole` (server-set attribution metadata; `None` on legacy contracts) at every `new Vote(...)` site. * DsoRules_RequestVote choice (now takes optional `bindingCid`): added trailing `Optional.empty()` to the SvApp caller; this caller is the operator path, so `bindingCid = None` is the correct value. * DsoRules_CastVote choice (now takes optional `bindingCid` and `castBy`): added two trailing `Optional.empty()` to both production callers (SvApp and CopyVotesTrigger), keeping them on the operator path. No semantic change. Governance-voter routing for these app paths is not wired up here; that's deferred to a follow-up. Signed-off-by: Eric Mann <eric@avrofi.com>
Records the SvGovernanceVoter binding under which each governance-voter cast was made (new Vote.bindingCid : Optional, appended at the end for upgrade). At close, DsoRules_CloseVoteRequest now takes an optional currentBindings : Optional [ContractId SvGovernanceVoter] argument; when supplied, the choice fetchCheckeds each (forcing the caller to provide only live bindings), builds an SV -> live-binding map, and drops any governance-voter-cast vote whose recorded bindingCid is no longer the live binding for its SV. Dropped voters are reported via a new DsoRules_CloseVoteRequestResult.staleBindingVoters : Optional [Text] (None when no check ran, Some [...] otherwise) -- mirroring the existing offboardedVoters channel. currentBindings = None preserves pre-staleness behavior for callers that have not yet adopted the new arg. Why pass the live set instead of trying to detect archived bindings intrinsically: SDK 3.4 cannot catch fetch-on-archived in the Update monad, so the close choice cannot independently distinguish "still authoritative" from "rotated". Passing the live set is the same trust shape as already used for amuletRulesCid and the controller sv. Tests in TestGovernance.daml exercise both branches: - testStaleBindingDropsVote: open a gov-voter-eligible request via a delegate, collect 4 accepts, rotate the represented SV's binding, close with currentBindings = [...live...] and assert the rotated-out voter shows in staleBindingVoters and the remaining 3 still satisfy the 3-of-4 threshold (VRO_Accepted). - testStalenessCheckOptIn: same setup but close with currentBindings = None; staleBindingVoters = None, all 4 votes count. Scala/Java codegen consumers (SvApp, CopyVotesTrigger, CloseVoteRequestTrigger, SvDsoStoreTest, ScanStoreTest, StoreTestBase) are updated to thread the new Optional fields with empty defaults. Signed-off-by: Eric Mann <eric@avrofi.com>
Use the SvGovernanceVoter self-binding for governance-voter-eligible request and cast submissions from the SV app, copy votes via the governance-voter path when required, and pass live bindings into automated close-vote submissions so staleness is actually checked. Signed-off-by: Eric Mann <eric@avrofi.com>
Require close-vote staleness checks to supply exactly one live binding for each active SV, and add DsoRules/SV automation cleanup for orphaned or duplicate SvGovernanceVoter bindings left by offboarding, re-onboarding, or older package versions. Signed-off-by: Eric Mann <eric@avrofi.com>
Keep the PR to a single splice-dso-governance package version bump from 0.1.24 to 0.1.25. Rebuild the final DAR contents under 0.1.25, remove the intermediate 0.1.26-0.1.35 DARs, and update dars.lock accordingly. Signed-off-by: Eric Mann <eric@avrofi.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR is a prototype for maintainer review and discussion, not necessarily a final upstream design. It makes the proposed Phase 1 SV governance-voter model concrete for discussion under canton-foundation/canton-dev-fund#223, especially Milestone 1: Governance-Voting Identity and CIP.
Phase 1 preserves the existing one-vote-per-SV semantics. A governance voter is modeled as an alternate signer for a represented SV's vote, not as a new voting unit or source of additional vote weight.
Changes included:
isGovernanceVoterAction, with newActionRequiringConfirmationconstructors rejected by default until explicitly reviewed.VotewithcastByandcastByRoleso vote records distinguish the represented SV from the party/authority path that signed the vote.SvGovernanceVoter, an SV-declared binding template without a contract key; downstream paths fetch it by contract ID and validate the one-active-binding assumption at the workflow level.DsoRules_CastGovernanceVote, which validates the binding, represented SV, governance-voter signer, signer role, and action allowlist before writing the vote into the represented SV's existing vote slot.(Internal) Design IDs covered: GV-001, GV-002, GV-003, GV-004, GV-005, GV-006.
Notes For Reviewers
SRARC_OffboardSvis included in the proposed Phase 1 allowlist so reviewers can evaluate it explicitly; this should be validated through maintainer/CIP review because it is a high-impact membership action.governanceVoter == svis allowed for bootstrap/self-voting, whilegovernanceVoter == dsois rejected.Test Plan
direnv exec . sbt "splice-dso-governance-test-daml/damlTest"direnv exec . sbt damlDarsLockFileUpdate