Skip to content

fix(topology): published party proposals only at submit - #526

Merged
schronck merged 8 commits into
mainfrom
fix/topology/no-early-publish-448
Oct 13, 2026
Merged

schronck merged 8 commits into
mainfrom
fix/topology/no-early-publish-448

Conversation

@schronck

@schronck schronck commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Kick, add-party and change-threshold now publish their topology proposals only at submit. Before, the coordinator built each proposal with Authorize against the synchronizer store. Authorize distributes the proposal, and it authorizes outright when this node alone holds enough keys. At namespace threshold 1 the namespace change took effect at once, while the paired participant change still waited for the members.

The coordinator now signs both proposals in a temporary topology store. Canton keeps that store in memory and distributes nothing from it. Submit checks both serials against the synchronizer before it publishes anything. Add-party still publishes its P2P proposal before the signing round, because the new member needs it (#469). That proposal cannot take effect without the new member's signature.

Related issues

Closes #448

Type of change

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

Checklist

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

All CI checks pass on 833e818. The full Canton integration run ran the new no_early_publish phase and the extended G1 phase. The first run on 9fbb03f failed in add-party, see "Add-party's P2P" below.

Notes for reviewers

Root cause

create_proposals called Authorize with the synchronizer store as the target. The proto says Authorize proposes and distributes. A threshold-1 namespace needs only one owner, so the coordinator's own signature applied the DNS during CreateProposals. The applied DNS carried the new threshold. A migration from 1 to 3 then needed three signatures to repair.

Signing without publishing

The new workflow::proposal_store module signs a run's DNS and P2P in one temporary store. The flow is create, seed, sign at head + 1, then drop.

Canton accepts an explicit serial only as the successor of the store's own head. An empty store therefore accepts serial 1 and nothing else. The ported change seeded the store with the synchronizer's head transactions. Canton judges a transaction without a predecessor as a creation. A creation needs every DNS owner, or every P2P host's namespace, and a head usually carries only a quorum. So the seed is refused.

The seed now replays the party's full DNS and P2P history in effective-time order. It also replays the namespace delegations of every owner and host that ever signed. Each transaction is then judged against the state it was signed for. Signing starts only when the store's head serials equal the synchronizer's.

The replay passes four force flags. Canton reruns its dangerous-change checks relative to this node and its current ledger state. A past unhosting of this node would otherwise fail the active-contracts check.

Add-party's P2P

The new member waits until the P2P proposal that hosts it reaches its own synchronizer store. It then disconnects and signs (#469). Its ACS import refuses to run without that mapping in its store. A synchronizer store holds only sequenced transactions, so the first integration run timed out there.

The coordinator therefore publishes the add-party P2P proposal, signed by itself alone, right after signing. Canton requires an added host's own signature on the mapping that adds it. The proposal stays pending until the new member signs. The create step refuses to publish a P2P that does not add the new member. The add-party DNS still waits for submit. This was the old timing for the P2P, and it is the one exception to "nothing before submit".

Fallback above threshold 1

The temporary store stays the first choice for every party. A replay can still fail to rebuild the heads, for example on a history with revoked delegations. The coordinator then decides by the party's current namespace threshold:

  • At threshold 1 the run stops with nothing signed or published. Only at threshold 1 can the coordinator's signature alone put the DNS in force.
  • At threshold 2 or more the coordinator signs with Authorize against the synchronizer store, as before Topology proposals are published when they are created, so a threshold-1 party can half-apply a migration #448. It logs one warning that names the party and the replay failure. Its signature alone cannot meet the threshold, so the proposals stay pending until the members sign. The serials stay pinned to head + 1.

A participant that cannot be reached is not a failed replay. Transport errors such as gRPC Unavailable or an exceeded deadline propagate on both paths, and the step's retry handles them. The pure function signing_route in workflow/proposal_store.rs makes the decision, and unit tests cover each case.

Submit's serial check needs no change for the fallback. A proposal published while pending is not a head, so it still reads as head + 1 and goes out again with the peers' signatures. Add-party keeps its early P2P publish on both paths; Canton ignores the repeat.

Serials

The create steps read both head serials together with the mappings they build on. A create step refuses to build when the namespace differs from what ExportState read. Peers keep pinning head + 1, unchanged.

Submit reads both heads before it publishes anything:

  • A proposal at head + 1 is published.
  • A proposal equal to the head is in force from an earlier attempt and is skipped. This covers a coordinator restart between the DNS and the P2P.
  • Any other serial stops the run with nothing published.

Canton also refuses a stale explicit serial with SERIAL_MISMATCH, so a moved serial never overwrites. The pre-submit check matters because the DNS goes first. Without it, a moved P2P serial leaves the DNS applied and the P2P refused.

Temporary store lifecycle

  • A store name is decman-proposals-<instance>~<attempt>.
  • close drops the store after success and after an error.
  • A drop guard spawns the drop when a cancel aborts the task.
  • Opening a store first drops every store of the same instance. A run that resumes after a crash cleans up its own earlier attempt.
  • Boot drops every decman-proposals-* store before any run resumes. The sweep has a 15-second bound.
  • A participant restart clears temporary stores as well, because Canton keeps them in memory.

Resume does not need a store. The signed proposals are persisted as artefacts before the step advances.

The boot sweep assumes one decman per participant.

Mixed-version peers

The wire format does not change. Peers still decode the proposal, check head + 1 and sign with SignTransactions.

A coordinator from before this change still publishes early. New peers keep accepting a proposal that equals the head at the head serial for that reason. The comment and the architecture guide now say why.

Other paths checked

Tests

Unit tests cover the store lifecycle against a recording stand-in for the admin API: success, error, abort, resume and the boot sweep. They also cover the replay order, store names, the protocol-version parse, the submit-time serial check, the add-party host guard and the fallback decision.

The new no_early_publish Canton phase runs while the shared party is at threshold 1:

  • Signing directly pins head + 1 and changes nothing on the synchronizer for 10 seconds.
  • After the P2P serial moves twice, submit refuses the stale pair. The namespace stays at threshold 1.
  • A real change-threshold run from 1 to 2 pauses P2 with SIGSTOP after it joins. P3 signs, P2 never does. Both mappings stay unchanged before and after the cancel.

Each step checks that no temporary store is left on P1's participant. The coordinator-restart phase (G1) plants a leftover store and asserts that the boot sweep drops it.

Not tested

  • Devnet and mainnet histories. The replay is exercised only on the localnet party. A long history with rotated or revoked delegations is the likeliest place for a replay to fail. Such a party falls back above threshold 1 and stops at threshold 1. No integration test drives the fallback; unit tests cover the decision.
  • Kick and add-party at threshold 1. The suite runs them at threshold 2. Their create and submit paths share the code the threshold-1 phase exercises.
  • A task aborted during the CreateProposals window itself. The unit test covers the drop guard.

@schronck
schronck requested a review from scolear October 9, 2026 15:16
@schronck
schronck marked this pull request as ready for review October 9, 2026 15:29
@schronck
schronck requested a review from a team October 9, 2026 15:29
@schronck schronck self-assigned this Oct 13, 2026

@scolear scolear left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed 833e818.

The change is sound. The coordinator signs the DNS and the P2P in a temporary store, and Canton distributes nothing from that store. Submit reads both heads before it publishes anything. proposal_standing refuses a moved serial, and it compares the full transaction before it treats a proposal as in force. The signed transaction bytes do not change between the temporary store and the synchronizer, so the peer signatures stay valid.

Store lifecycle: in_store drops the store after success and after an error. The Drop guard spawns the drop when a cancel aborts the task. Each attempt gets its own name, so a late drop never lands on the next attempt. open drops the earlier attempt of the same instance, and the boot sweep drops the rest. The G1 phase plants a leftover store and asserts the sweep removes it.

Add-party: adds_host refuses to publish a P2P that does not add the new member. The add-party DNS waits for submit. Kick keeps the DNS first and the P2P second, which is the safe order: the kicked member loses namespace authority before it loses hosting.

Crash paths: a run that persisted the signed pair resumes at submit and re-checks the serials. It does not sign again. A run that crashed after the DNS went out finds the DNS at the head and sends only the P2P.

Tests: unit tests cover the serial check, the store lifecycle, the replay order and the fallback decision. The no_early_publish phase covers the threshold-1 scenario, and the following restore the threshold to 2 round drives a real threshold-1 run through submit.

Two points I could not verify from the code alone. First, that Canton requires an added host's own signature on the P2P that adds it. The CI add-party run supports it, and it matches the Canton docs I know. Second, that a devnet or mainnet history replays into the store. The fallback and the threshold-1 refusal bound the damage if it does not.

One residual window remains, and the PR names it: a P2P serial can still move between the pre-check and the P2P publish. The DNS is then in force and the P2P is refused with SERIAL_MISMATCH. That window is seconds wide and the old code had no check at all, so this is not a blocker.

Comment thread crates/decman/src/workflow/topology.rs Resolved
…shing them

Add-party, kick and change-threshold built each proposal with
Authorize against the synchronizer store. Authorize proposes and
distributes, and it authorizes outright when this node alone holds
enough keys. At namespace threshold 1 the DNS was therefore in force
the moment it was created, while the paired P2P still waited for the
other members, so a member that never signed left the party half
changed (#448).

Each proposal is now authorized in a temporary topology store, which
signs without distributing, and the store is dropped afterwards.
Nothing reaches the synchronizer until submit publishes the pair with
AddTransactions. A temporary store has no history, so both serials are
read from the synchronizer and passed explicitly as current + 1, and
the store is seeded with the party's mappings before the proposal is
authorized in it. The interim threshold-1 warning goes, since the
namespace no longer applies early.

Ported from the unpushed fix/topology/no-early-publish branch, where
this was 93341e27 (re-applying the reverted GenerateTransactions
route), e1c66fa9, e388c12b and 53febd6e. The four are squashed here:
the GenerateTransactions route they start from is replaced within the
same range. Conflicts with main were resolved by keeping main's
re-host path in add-party and its *_at_head topology readers.
The ported change seeded each temporary store with the synchronizer's
head transactions. Canton judges a transaction that has no predecessor
in its store as a creation: a DNS then needs every owner's signature
and a P2P every hosting participant's namespace. The heads usually
carry only a quorum, so the seed was refused and the store kept no
head to pin the serial against. The seed also read ListAllV2, which
Canton 3.5 does not serve.

The new proposal_store module rebuilds the heads instead. It replays
the party's whole DNS and P2P history in effective-time order, with the
namespace delegations of every owner and host that signed it, so each
transaction is judged against the state it was signed for. It signs
only once the store's heads match the synchronizer's serials, at head
+ 1 for each mapping.

One store now serves both proposals of a run. Its name carries the
run's instance name and an attempt suffix. It is dropped after
signing, after an error, and by a drop guard when a cancel aborts the
task. Opening a store first drops what an earlier attempt of the same
run left, which covers a coordinator that restarts mid-step.

The create steps read the head serials together with the mappings they
build on, and refuse to build when the namespace changed after
ExportState read it.
A coordinator killed between creating a temporary topology store and
dropping it leaves the store on its participant. Canton keeps such
stores in memory, so a participant restart clears them, but a
participant can run for months. A run that resumes drops its own
leftovers when it next signs, which leaves only the runs that never
resume.

The boot now drops every decman-proposals-* temporary store before it
recovers any run. It has to run first: a resumed run creates a store of
its own, and a sweep alongside it would drop that store. The sweep is
bounded to 15 seconds so a participant that is down does not hold up
the boot.
The coordinator pins each proposal's serial when it signs it, and the
peers sign that serial. A change that lands before submit moves the
synchronizer's head. Canton then refuses the stale transaction, but
the DNS is submitted first: if only the P2P serial moved, the DNS went
in and the P2P was refused, which is the half-applied party #448 is
about.

Submit now reads both heads before it publishes anything. A proposal
at head + 1 goes out. A proposal that is the head itself is in force
from an earlier attempt of the same submit, so a coordinator that
restarts between the DNS and the P2P resumes without sending it again.
Anything else stops the run with nothing published and names both
serials. The P2P is checked again right before it goes out.
The architecture guide listed Authorize as the call that signs the
kick, add-party and change-threshold proposals. It now names the
temporary store, the history replay that pins the serials, the
submit-time serial check and how the stores are cleaned up.

The peer still accepts a proposal that equals the head at the head
serial. The guide and the code comment now say why: a coordinator built
before #448 puts a threshold-1 namespace in force when it proposes, and
peers must keep working with it during an upgrade.
…nton

A new no_early_publish phase runs from change_threshold while the
shared party sits at threshold 1, where the coordinator's signature
alone authorizes a namespace change:

- The coordinator's signing step, called directly, pins head + 1 for
  both mappings and publishes nothing.
- After the P2P serial moves under a signed pair, submit refuses the
  pair and the namespace stays at threshold 1. Without the pre-submit
  check, P1 alone would put the DNS in force.
- A real change-threshold run raises 1 to 2 while P2 joins and is then
  paused, so it never signs. Both mappings must stay as they were,
  before and after the run is cancelled.

Each step also checks that P1's participant holds no temporary store.
The coordinator-restart phase (G1) now plants a leftover store before
it kills P1 and asserts the boot sweep drops it.

The harness gains pause and resume (SIGSTOP / SIGCONT) for a node, and
a lookup of the peer row that joined a coordinator run.
The integration run timed out in add-party. The new member waits until
the P2P proposal that hosts it reaches its own synchronizer store, and
only then disconnects and signs (#469). Its ACS import refuses to run
without that mapping in the store, and a synchronizer store holds only
what was sequenced. With the proposal signed in a temporary store, it
never arrived.

The coordinator now publishes the add-party P2P proposal, signed by
itself alone, right after it signs the pair. That cannot put the
mapping in force early: Canton requires an added host's own signature
on the mapping that adds it, so the proposal stays pending until the
new member signs. The create step refuses to publish a P2P that does
not add the new member. The DNS still waits for submit, so a member
that never signs leaves the namespace unchanged.
…ails

The history replay can fail to rebuild a temporary store's heads, for
example on a history with revoked delegations. Every topology workflow
for that party then stopped at CreateProposals.

The temporary store stays the first choice for every party. When the
replay fails, the coordinator now decides by the party's current
namespace threshold:

- At threshold 1 the run still stops with nothing signed or published.
  Only there can the coordinator's signature alone put the DNS in
  force.
- At threshold 2 or more it signs with Authorize against the
  synchronizer store, as before #448, and logs one warning. Its
  signature alone cannot meet the threshold, so the proposals stay
  pending until the members sign. The serials stay pinned to head + 1.

A participant that cannot be reached is not a failed replay. A gRPC
Unavailable, a deadline and similar transport errors propagate on both
paths, so the step's retry handles them. A transport error while
replaying a single transaction is no longer swallowed.

The decision is the pure signing_route function, unit-tested for each
case. Submit's serial check needs no change: a proposal published while
pending is not a head, so it still reads as the next serial. The
architecture guide gains the fallback, and the Change Threshold section
gets back its participant minimum, which the earlier docs commit moved.
@schronck
schronck force-pushed the fix/topology/no-early-publish-448 branch from 833e818 to eaef013 Compare October 13, 2026 10:10
@schronck
schronck merged commit 84822d4 into main Oct 13, 2026
10 checks passed
@schronck
schronck deleted the fix/topology/no-early-publish-448 branch October 13, 2026 10:23
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.

Topology proposals are published when they are created, so a threshold-1 party can half-apply a migration

2 participants