Repository navigation
fix(topology): published party proposals only at submit - #526
Conversation
scolear
left a comment
There was a problem hiding this comment.
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.
…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.
833e818 to
eaef013
Compare
Summary
Kick, add-party and change-threshold now publish their topology proposals only at submit. Before, the coordinator built each proposal with
Authorizeagainst the synchronizer store.Authorizedistributes 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
Checklist
cargo fmt -- --checkpassescargo clippy --workspace --all-targets --all-features -- -D warningsis cleancargo test --workspacepasses (1,155 passed; 3 ignored)<type>(<scope>): <subject>conventionAll CI checks pass on 833e818. The full Canton integration run ran the new
no_early_publishphase 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_proposalscalledAuthorizewith the synchronizer store as the target. The proto saysAuthorizeproposes and distributes. A threshold-1 namespace needs only one owner, so the coordinator's own signature applied the DNS duringCreateProposals. 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_storemodule 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:
Authorizeagainst 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
Unavailableor an exceeded deadline propagate on both paths, and the step's retry handles them. The pure functionsigning_routeinworkflow/proposal_store.rsmakes 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
ExportStateread. Peers keep pinning head + 1, unchanged.Submit reads both heads before it publishes anything:
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
decman-proposals-<instance>~<attempt>.closedrops the store after success and after an error.decman-proposals-*store before any run resumes. The sweep has a 15-second bound.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
PartyToKeyMappingRemove still goes throughAuthorizeagainst the synchronizer store. A threshold-1 party applies it with the coordinator's signature alone. It runs only after the inline keys are in force and validated, and it pairs with no other mapping. The failure mode here does not apply, so this PR leaves it unchanged.Authorize. Only the joining participant can authorize it, so it cannot take effect early.check_added_signing_keys_signed(Adopt a legacy party's signing keys when adding a member #443) still runs before submit.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_publishCanton phase runs while the shared party is at threshold 1:SIGSTOPafter 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
CreateProposalswindow itself. The unit test covers the drop guard.