Skip to content

Prototype SV governance voter flow#5533

Open
ericmann wants to merge 18 commits into
canton-network:feature-sv-vote-dappfrom
Avro-Digital:eric-avro/prototype-sv-governance-voter-flow
Open

Prototype SV governance voter flow#5533
ericmann wants to merge 18 commits into
canton-network:feature-sv-vote-dappfrom
Avro-Digital:eric-avro/prototype-sv-governance-voter-flow

Conversation

@ericmann
Copy link
Copy Markdown
Contributor

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:

  • Add a hardcoded governance-voter action taxonomy via isGovernanceVoterAction, with new ActionRequiringConfirmation constructors rejected by default until explicitly reviewed.
  • Extend Vote with castBy and castByRole so vote records distinguish the represented SV from the party/authority path that signed the vote.
  • Add 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.
  • Add 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.
  • Add Daml tests for the action taxonomy, vote-slot/tally semantics, binding lifecycle and authorization, and governance-voter casting/overwrite scenarios.
  • Add an SV-operator documentation note describing the prototype scope, proposed allowlist, attribution semantics, binding invariant, and explicit contract-ID submission shape.

(Internal) Design IDs covered: GV-001, GV-002, GV-003, GV-004, GV-005, GV-006.

Notes For Reviewers

  • SRARC_OffboardSv is 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 == sv is allowed for bootstrap/self-voting, while governanceVoter == dso is rejected.
  • The binding is unilateral/SV-declared in this prototype. If maintainers prefer bilateral consent, this can move toward a Propose-Accept shape in a follow-up revision.
  • The binding intentionally has no contract key in this prototype. The one-active-binding-per-SV rule is treated as an invariant to validate rather than a template-key constraint.

Test Plan

  • direnv exec . sbt "splice-dso-governance-test-daml/damlTest"
  • direnv exec . sbt damlDarsLockFileUpdate

ericmann added 6 commits May 13, 2026 16:39
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>
Comment thread daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml Outdated
Comment thread daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml Outdated
Comment thread daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml Outdated
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>
Comment thread daml/splice-dso-governance/daml/Splice/DSO/GovernanceVoter.daml
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>
Comment thread daml/splice-dso-governance/daml/Splice/DsoRules.daml
ericmann added 7 commits May 14, 2026 16:44
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>
ericmann added 3 commits May 14, 2026 21:19
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>
isGovernanceVoterAction : ActionRequiringConfirmation -> Bool
isGovernanceVoterAction action = case action of
ARC_DsoRules with dsoAction -> case dsoAction of
SRARC_OffboardSv _ -> True
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.

Is that deliberate? I was expecting this to be an operational concern as it often needs to be acted upon quickly.

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.

Yes, we discussed this. offboarding is one of those where it is very unclear what the right choice is.
Some offboardings are because the node is broken, some because of a governance change.

For now, let's ignore which action is governance vs operational, that will be a separate discussion in parallel to the design here, that should not affect design choices.

SRARC_SetConfig _ -> True
SRARC_UpdateSvRewardWeight _ -> True
SRARC_CreateUnallocatedUnclaimedActivityRecord _ -> True
_ -> False
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.

I would still recommend to not have a wildcard here and and instead explicitly match on all constructors. That way when a new one gets added you're forced to think about how it should be treated which is much better than getting the wrong default.

-- ^ The time before which votes are accepted, and SHOULD be submitted.
votes : Map.Map Text Vote
-- ^ The votes cast by current or previous SVs. These may be previous SVs in case
votes : Map.Map Party Vote
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.

That doesn't work, it's not a backwards compatible change.

data Vote = Vote with
sv : Party -- ^ The SV party used to submit the vote.
sv : Party -- ^ The represented SV whose vote slot is updated.
castBy : Party -- ^ The party that signed the vote command.
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.

these also need to be optional

-- Controlled by the governance-voter party authenticated via the binding;
-- the represented SV is taken from the binding. The initial vote is
-- recorded against the represented SV's slot with VCR_GovernanceVoter.
nonconsuming choice DsoRules_RequestGovernanceVote : DsoRules_RequestGovernanceVoteResult
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.

do we really need a new choice or can we just add biningCid as an optional argument to the vote choice and then in the choice branch on whether it's a governance or non-governance choice?

-- Note that this choice can be used to both cast the initial vote, and update a vote.
--
-- Operator path: only for *operational* actions. Governance-voter eligible
-- actions are cast exclusively via DsoRules_CastGovernanceVote.
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.

same question here, it's not clear to me why we need two separate choices

observer dso, governanceVoter

ensure
sv /= dso
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.

not sure why we need those assertions, I don't think we do that anywhere else.

Comment thread daml/splice-dso-governance/daml/Splice/DSO/GovernanceVoter.daml
-- self-binding. Hard enforcement of the "every SV has at least one active
-- binding" invariant is deferred; the Phase 1 expectation is that the SV
-- workflow uses RotateGovernanceVoter rather than direct archive.
template SvGovernanceVoter
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.

I'm rather nervous about the signatories on that. Having the SV as the signatory means we also need the SV as a confirmer for any transaction involving this which is often not what you want. If you look at the exiting contracts for votes, prices, … you see that they are all very careful to only have the DSO as a signatory. Is there anything that stops us from doing the same here and create this through dso rules? You could even then do the initial creation with confirmation and deduplication to avoid duplicates.

governanceVoter : Party
where
signatory sv
observer dso, governanceVoter
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.

I'm wondering if you really need the governanceVoter as an observer or whether you can read it through scan or something like that. That way you would fully avoid any potential issues with the node potentially unvetting the dar and breaking something. It could be that we're ok with that but if we are we need a careful analysis of that.

nonconsuming choice DsoRules_RequestGovernanceVote : DsoRules_RequestGovernanceVoteResult
with
governanceVoter : Party
bindingCid : ContractId SvGovernanceVoter
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.

What staleness are we willing to accept? Let's say at time X someone creates a vote at this point SvGovernanceVoter is still pointing to party A. When the vote gets executed it now points to party B.

I'd lean towards saying this needs to get revalidated similarly to how we don't allow the votes of an offboarded SV to still have an effect.

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.

7 participants