Authenticated governance replication (#2469)
Status: design + slice 1 (primitive) + slice 2 (#2583) + slice 3 (signed emission).
Derived against: origin/main = 6754d30c83cc944e5ad22e4687e5bb63ac8dc51e.
Owns: the durable replacement for the #2470 containment.
Refs: #2469, #2441, #2470 (bc291305), #2471, #2480, #2510, #2520, #2535, #2544, #2583.
Everything in §1 was re-derived from source — originally at c3782321, re-verified at
3d401dce. Where an older campaign note disagrees, the source wins and the disagreement is
called out.
Revision 2 corrects three mechanisms from revision 1 that were unsafe under
eventually-consistent delivery: the sequence gate, first-writer-wins collision
arbitration, and whole-config authority binding. Revision 1's prev hash-chain field is
also removed. See §10, §5.2, §5.3.
Revision 3 re-derives §1 against 3d401dce, where slice 2 has landed as #2583:
gossip now re-derives entry.hash from the payload before an unseen entry may claim a
content-addressed slot. T3 is closed, and §5.6 step 6 became an upstream guarantee rather
than work this design still has to schedule. No architectural premise changed — #2583
authenticates payload↔digest only, and leaves authorship, authority and the #2470
containment exactly as they were. Revision 3 also corrects a §7.0 mis-citation: the vote
suspension gate was cited at the proposer gate's line.
Revision 4 lands slice 3 (signed emission) at 6754d30c and records what wiring the
emission path revealed. Two findings are load-bearing for slice 7 and are written up in
§7.0.2 and §7.0.3: a node can only sign for itself, which removes gateway-hosted votes
from the signable set for a reason independent of §7.0.1; and per-federation governance
topics are never created, so that route carries nothing today. Neither moves the field set.
§13 records the slice as done.
1. Verified current behavior — source-to-sink trace
1.1 Outbound: operator action → wire
| # | Step | Location |
|---|---|---|
| 1 | Operator issues a GovernanceCommand (RPC/HTTP) |
icn-rpc/src/handler/governance.rs, apps/governance/src/http/handlers.rs |
| 2 | GovernanceActor persists to GovernanceStateStore first |
apps/governance/src/actor.rs command arms |
| 3 | Actor builds a GovernanceMessage, serializes with to_bytes() = serde_json::to_vec |
icn-governance/src/message.rs:417 |
| 4 | gossip.publish(topic, data, author = own_did); ACL checked for the local DID only; hash = Self::hash_data(&data) |
icn-gossip/src/gossip.rs:858, :925 |
| 5 | store_entry(entry) → fires notification callbacks (including governance's own loopback copy) |
icn-gossip/src/gossip.rs:974 |
| 6 | Outbound send wraps the GossipMessage in a SignedEnvelope — PayloadType::Gossip, Ed25519 by the node keypair, durable monotonic sequence, fail-closed if the sequence cannot be persisted (#2510) |
icn-core/src/supervisor/init_send_callback.rs:163 |
| 7 | Sent as MessagePayload::Signed |
icn-net/src/protocol.rs:628 |
Honest nodes already sign every outbound gossip message.
1.2 Inbound: wire → state
| # | Step | Location |
|---|---|---|
| 8a | MessagePayload::Signed → ctx.handle_signed() → envelope.verify(max_age_secs): Ed25519 over canonical_encoding() (sequence ‖ timestamp ‖ payload_type ‖ payload), age check, replay guard |
icn-net/src/actor/connection.rs:857, handlers/signed.rs:360, replay_guard.rs |
| 8b | MessagePayload::Gossip (raw, unsigned) is also accepted and forwarded to the same external handler |
icn-net/src/actor/connection.rs:822 |
| 9a | Signed path → gossip.handle_message(&envelope.from, msg) — from is authenticated |
icn-core/src/supervisor/init_network.rs:225–255 |
| 9b | Unsigned path → gossip.handle_message(&net_msg.from, msg) — from is self-declared |
icn-core/src/supervisor/init_network.rs:59–71 |
| 10 | handle_message_inner: policy-oracle check on sender (Domain::trust(), ActionKind::Read). A coarse trust gate, not an authorship check |
icn-gossip/src/protocol.rs:46–68 |
| 11 | handle_response — ignores sender entirely, stores the entry verbatim |
icn-gossip/src/handlers/push.rs:109–121 |
| 12 | store_entry: re-derives entry.hash from the payload before an unseen entry may claim a content-addressed slot (#2583); still no topic ACL; still no signature — GossipEntry has no signature field |
icn-gossip/src/gossip.rs:974, types.rs:116–144 |
| 13 | Fires EntryNotificationCallback = Fn(String, GossipEntry, Did). No sender. No origin discriminator. |
icn-gossip/src/gossip.rs:38 |
| 14 | Governance callback: topic filter → from_bytes → observe_replicated_governance_message → debug! only. No state mutation. |
apps/governance/src/actor.rs:1277–1303, :3843 |
1.3 The verified facts that constrain the design
GossipEntrycarries no signature.author: Didis attacker-chosen.- The entry hash is re-derived on receipt, as of #2583 (
3d401dce).store_entry(gossip.rs:974) refuses an unseen entry whose payload does not hash to its claimedentry.hash, viaGossipEntry::validate_content_integrity(types.rs). A slot already owned by a validated entry short-circuits to a read-only duplicate lookup, so replayed compressed bytes are never re-decompressed. This closes T3 and satisfies §5.6 step 6. It authenticates payload↔digest only —entry.authorremains an unsigned, self-declared field that any peer may set to any DID, which is why fact 1 still stands. - The transport peer is authenticated — Hello binds DID → live TLS cert
(
handlers/hello.rs:44–108), fails closed with no peer certificate. - That identity is discarded at the gossip seam.
- The unsigned gossip door is open (
connection.rs:822). SignedEnvelopeauthenticates the hop, not the author.handle_response(push.rs:109) stores a received entry verbatim;handle_request_missing(pull.rs:461) re-servesentry.clone()onward under the relay's own envelope signature.entry.authorcrosses every hop unauthenticated. Any design requiringenvelope.from == entry.authoraccepts only single-hop delivery and destroys relay and anti-entropy convergence.- The DID is the public key.
Did::to_verifying_key()(icn-identity/src/lib.rs:255). Verification needs no resolution, no DID document, no network round-trip. GovernanceDomainhas no owner, creator, or admin field (domain.rs:33–50).GovernanceDomainId::generate()is a random UUID; the domain declares its own membership.MembershipSource::TrustThresholdis node-subjective (resolver.rs:71–75).save_domain/save_proposal/save_voteare LWW upserts (state_store.rs:32,43,54).VoteCast.signatureis dead (only produceractor.rs:2090passesNone);GovernanceDomainSeedManifest(bootstrap.rs:114) has zero production consumers.
1.4 New in revision 2 — identity and key structure
All governance identifiers are caller-chosen strings.
ProposalId::generate()/GovernanceDomainId::generate()/DelegationId::generate()are random UUIDv4, and each type also exposesnew(impl Into<String>)accepting an arbitrary string (proposal.rs:243–255,domain.rs:9–21,delegation.rs:69–81). No identifier is bound to its creator.Exactly one storage key is author-bound. From
state_store.rs:192–247:Key helper Shape Binds principal? vote_key(proposal_id, voter)gov:vote:{pid}:{voter}Yes — the voter DID proposal_key(id)gov:proposal:{id}No domain_key(id)gov:domain:{id}No delegation_key(id)gov:delegation:{id}No proof_key(id)/close_intent_key(id)…:{pid}No The pre-containment mutation set was exactly 8 variants (from the
bc291305diff):DomainCreated,DomainUpdated,ProposalCreated,ProposalOpened,VoteCast,ProposalClosed,DelegationCreated,DelegationRevoked.Comment*,Deliberation*andReaction*were never persisted by the replication ingress —GovernanceStateStorehas no comment key space at all. Revision 1 listed them as restoration candidates; that was wrong, and replicating them would be a no-op.CastVoteoverwrites unconditionally (actor.rs:2072–2087) — no "already voted" check. A voter can change their vote, so the vote key is not write-once.GovernanceConfig={ profile, membership, params, emergency }(config.rs:25–37).params(quorum, approval thresholds) andemergencygovern how state is evaluated, not who may author.cooperative_default()usesMembershipConfig::trust_threshold(0.3)(config.rs:81) — i.e. the default profile is not supported by v1 (§5.5).
1.5 The authority-root finding
- Domain-anchored variants reference a domain directly or via
proposal_id. Authority could be membership in that domain — if the receiver already holds it. - Domain-defining variants (
DomainCreated,DomainUpdated): per fact 8, no authority root exists in the data model. A signature proves only "some keypair said so."
2. Threat model
Attacker: can open a QUIC connection, complete Hello (so holds some valid DID — DIDs are free to mint), and send arbitrary frames. Does not hold any victim's private key. May be a legitimate federation member acting outside its authority.
| # | Attack | Status on 3d401dce |
|---|---|---|
| T1 | Forge entry.author as a victim DID and publish governance state |
Blocked by containment; would otherwise fully succeed |
| T2 | Publish raw unsigned MessagePayload::Gossip |
Open (connection.rs:822) |
| T3 | Supply entry.hash unrelated to content → poison dedup, pre-claim a hash to suppress a legitimate entry |
Closed by #2583 (3d401dce) — re-derived in store_entry |
| T4 | Replay a legitimately signed op into a different domain | Open under a signature that omits domain binding |
| T5 | Replay a superseded op | Open without a replay identity |
| T6 | ProposalOpened force-reset on a terminal proposal, then ProposalClosed — two-message finalization bypass |
Blocked; must stay blocked regardless of authentication |
| T7 | Collide a GovernanceDomainId / ProposalId / DelegationId and overwrite via LWW |
Open in the data model (facts 12–13); masked by containment |
| T8 | Member of domain A authors an op affecting domain B | Open without explicit per-domain authority |
| T9 | Relay tampers with a relayed entry's contents | Open — no content binding survives a hop |
| T10 | Two honest nodes disagree on TrustThreshold membership → divergent verdicts |
Latent; resolved by the O1 decision (§5.5) |
| T11 | Delivery-order divergence — two honest nodes apply the same op set in different orders and reach different final state | Latent; the subject of §10 |
| T12 | Sequence-gap denial — an attacker (or ordinary loss) causes an op to be discarded permanently because a higher sequence arrived first | Would be introduced by revision 1's design; corrected in §10 |
Out of scope per #2469: TLS redesign, governance consensus, the GovernanceProofV2
attestation-signer authority model, generic gossip ACL redesign.
3. Required invariants
- I1 Content binding. Signature over the exact op bytes, verified before any state read the op could influence.
- I2 Author binding. Verifies under
author.to_verifying_key();authoris the acting principal — notentry.author, notenvelope.from. - I3 Domain binding. The signed bytes include the affected
GovernanceDomainId. - I4 Authority.
authorheld a specific authority over that specific domain. - I5 Origin vs relay. Relay is untrusted; only the origin signature counts.
- I6 Replay identity. Applying the same op twice is a no-op.
- I7 Lifecycle monotonicity. Terminal proposals never regress, independently of authentication.
- I8 Conflict safety. Colliding IDs must not permit silent overwrite.
- I9 No circular bootstrap. Verification never fetches authority material over the channel carrying the op.
- I10 Deterministic verdict. Two honest nodes with the same durable state reach the same accept/reject decision.
- I11 Positive convergence. A legitimate op from another node is applied.
- I12 Order-independent convergence (new). Two honest nodes that receive the same set of ops in any order reach the same final state. No op is permanently discarded because of arrival order.
4. Design alternatives
A. Signed GossipEntry (generic content envelope)
Against: changes the shared gossip wire format for every subsystem at once; requires every entry producer to hold a signing key; delivers no authority (I4). The "generic gossip security" broadening #2469 warns against.
B. Transport rebinding + sender-authored-entry requirement
Disqualified. Per fact 6, requiring envelope.from == entry.author accepts only
single-hop delivery, breaking relay and anti-entropy (violates I5, I11). Closing the
unsigned door (T2) remains worth doing separately under #2471/#2480.
C. Governance-specific signed replication envelope — recommended
Carried inside entry.data, which gossip already treats as opaque. Minimal blast radius;
carries domain binding (I3) and authority context (I4), which A and B structurally cannot;
survives relay because the signature is inside the replicated content (I5).
D. Typed ReplicationPolicy at the shared topic-registration seam (#2441)
The name already exists at icn-kernel-api/src/state.rs:29 as a durability/placement
policy — #2441's proposal collides head-on. Substantively, the three #2441 classes have
different authority roots; a shared seam would be a framework built before any instance is
proven. Prove the governance instance first.
E. Reuse SignedEnvelope
Hop authenticator (fact 6), no domain binding, no authority model. Reuse the pattern, not
the type — specifically its length-prefixed canonical_encoding() and its
sign-the-transmitted-bytes discipline (§5.4).
5. Recommended minimal architecture
5.1 The v1 envelope
pub struct SignedGovernanceOp {
pub version: u16, // GOV_OP_V1; unknown → reject
pub author: Did, // acting principal; key via to_verifying_key()
pub domain_id: GovernanceDomainId, // explicit, never inferred from the payload
pub authority: ReplicationAuthority, // StaticMembership { membership_hash }
pub seq: u64, // per-(author, domain); COMPARATOR, never a gate
pub op_kind: GovernanceOpKind, // signed routing discriminator
pub op_bytes: Vec<u8>, // transmitted verbatim; signature covers these bytes
pub signature: Vec<u8>, // Ed25519 over canonical_body()
}
pub enum ReplicationAuthority {
StaticMembership { membership_hash: [u8; 32] },
}
op_id = SHA-256(canonical_body()) — derived, never carried, so it cannot disagree with
content.
Removed from revision 1: prev (hash chaining — see §10) and domain_config_hash
(over-binding — see §5.3). No timestamp field: wall-clock orders nothing here, so carrying
one would only invite it to be used.
5.2 Canonical encoding
canonical_body() is an explicit, hand-rolled, length-prefixed concatenation in the style of
SignedEnvelope::canonical_encoding (icn-net/src/envelope.rs:403):
"ICN-GOV-OP-v1\0" domain separator (fixed, unambiguous)
u16_be(version)
lp(author.as_str()) lp(x) = u32_be(len) ‖ bytes
lp(domain_id.0)
u8(authority discriminant) ‖ authority payload
u64_be(seq)
u8(op_kind discriminant)
lp(op_bytes)
Every variable-length field is length-prefixed, so no field boundary is ambiguous and no concatenation collision is reachable.
5.3 Why op_bytes is opaque, and why that is not "signing serde_json"
op_bytes are signed and transmitted verbatim: the receiver verifies the signature over
exactly the bytes it received, and decodes those same bytes. There is no re-serialization
step anywhere in the pipeline, so serde_json's determinism is never relied upon. This is
strictly stronger than canonicalizing GovernanceMessage, and it is the discipline
SignedEnvelope already uses (payload: Vec<u8> + payload_type).
Verbatim transport is guaranteed by verified fact 6: entry.data is opaque to gossip and
relays re-serve entry.clone() byte-for-byte.
The alternative — hand-rolling a canonical encoder for ~20 GovernanceMessage variants over
deeply nested types (Proposal, GovernanceDomain, TallySnapshot, GovernanceProofV2) —
would be a large, permanently-maintained surface where a newly added field is silently
excluded from the signature. That is a worse vulnerability class than the one it avoids.
Accepted consequence: JSON is malleable, so two byte strings can decode to the same
message and yield different op_ids. Not exploitable — an attacker cannot re-sign a
re-encoding — and the §10 comparator resolves any resulting duplicate deterministically.
5.4 The authority snapshot — membership only, not whole config
Revision 1 bound domain_config_hash. That conflates two different questions:
- who may author an operation → depends only on the member set;
- the rules under which the resulting state is evaluated →
params,emergency,profile.
Binding the whole config means an unrelated quorum-threshold edit invalidates every in-flight signed vote — a liveness failure with no security benefit, because a quorum change never grants anyone authorship.
v1 therefore binds exactly the authority-relevant snapshot:
membership_hash = SHA-256(
"ICN-GOV-STATIC-MEMBERSHIP-v1\0"
‖ lp(domain_id.0)
‖ u32_be(member_count)
‖ lp(member[0]) ‖ lp(member[1]) ‖ … // sorted bytewise, deduplicated
)
Sorted and deduplicated so the hash is a function of the set, not of list order.
domain_id is inside the hash so a membership snapshot cannot be lifted between domains
even if two domains happen to share a member set.
The ReplicationAuthority enum exists so a v2 basis (witness set, AuthorityGrant from
icn-governance/src/authority.rs, charter binding) can be added as a new variant that a v1
verifier rejects explicitly rather than misreads. One variant today; no abstract framework.
5.5 O1 resolved — StaticList only, and what that excludes
Decision: v1 supports only domains whose MembershipSource is StaticList.
TrustThreshold resolves against each node's local trust graph (fact 9), so two honest
nodes can legitimately compute different membership and reach different verdicts on the
same op — permanent divergence (T10, violating I10). It remains fail-closed for remote
state application until ICN has a separately designed deterministic membership-snapshot /
witness protocol.
Compatibility limitation, stated plainly:
GovernanceConfig::cooperative_default()usestrust_threshold(0.3)(config.rs:81). Domains built from the default cooperative profile are NOT eligible for authenticated replication in v1, and no part of #2469 attempts to make that configuration safe.- A
TrustThresholddomain behaves exactly as it does under the #2470 containment today: replicated governance ops targeting it are not applied. This is a no-op change for those domains, not a regression. - Eligibility is decided by the receiver's local copy of the domain. A node that holds a
StaticListdomain applies eligible ops; a node whose copy isTrustThresholddoes not. Operators must therefore not mix membership sources for the same logical institution across nodes — a mixed deployment converges only on theStaticListside.
5.6 Verification order at ingress (fail-closed, in this order)
- Decode the envelope framing; reject unknown magic or
version. - Reject
op_kindoutside the v1 restorable set (§7) — before decodingop_bytes, so an unsupported or oversized payload is never parsed. - Recover
author.to_verifying_key(); verifysignatureovercanonical_body(). No lookup, no network. → I1, I2 - Decode
op_bytes; reject if the decoded variant disagrees withop_kind. - Reject if the op's internal principal disagrees with
author(vote.voter). - Satisfied upstream since #2583 —
store_entryre-derivesentry.hashfrom the payload before any notification callback fires, so an entry reaching this ingress has already been shown to match its digest. Kept as a numbered step because the ingress contract depends on the property holding, not because this design must still build it. → T3 - Resolve
domain_idin local durable state. Absent → quarantine; never fetch. → I9 - Resolve the op's state anchor locally and confirm it belongs to
domain_id. ForVoteCast: loadvote.proposal_id; absent → quarantine; reject unlessproposal.domain_id == envelope.domain_id. Without this a member of domain D could vote into domain E's tally by declaringdomain_id = D— the signature, the membership check and the domain binding would all pass, because none of them looks at what the proposal belongs to. → I3, T8 - Reject unless the local domain's
MembershipSourceisStaticList. → §5.5 - Recompute
membership_hashfrom the local member set; reject mismatch. → I10 - Reject unless
author∈ the local member set. → I4 - Reject if
op_idis already in the durable applied-set. → I6 - Lifecycle: reject terminal-state regressions unconditionally. → I7, T6
- Apply under the §10 comparator, not LWW.
Note the ordering discipline: the allow-list gate (2) precedes the fallible decode (4), so the gate stays reachable and its counters mean something under attack.
6. Wire-format implications
- Gossip wire format: unchanged.
entry.datais already opaque bytes. - Governance topic payload: changed.
entry.databecomes aSignedGovernanceOpframe instead of a bareGovernanceMessage. - Frames are distinguished by a leading magic + version, never by trial decoding (a bare
GovernanceMessageis JSON; trial decoding is fragile and attacker-steerable). - The
entry.hashrecomputation (step 6) was a generic gossip change, separable, and fixes T3 for all topics independently of this envelope. Landed as #2583 (3d401dce).
7. First safe replication variant set
Applying facts 12–15 to the 8 pre-containment mutation variants:
| Variant | Storage key | Cross-author collision | Authority root | v1 |
|---|---|---|---|---|
VoteCast |
gov:vote:{pid}:{voter} — author-bound |
Impossible (§8) | membership + vote.voter == author |
RESTORE |
ProposalCreated |
gov:proposal:{id} |
Yes — any member may pick another's ProposalId |
membership | contain (§8) |
DelegationCreated |
gov:delegation:{id} |
Yes — same | membership | contain (§8) |
DelegationRevoked |
mutates existing gov:delegation:{id} |
n/a | revoked_by == delegator in local state |
contain — safe but inert (§7.1) |
ProposalOpened |
mutates gov:proposal:{id} |
n/a | undecided (§7.2) | contain |
ProposalClosed |
mutates gov:proposal:{id} + proof |
n/a | undecided (§7.2) | contain |
DomainCreated / DomainUpdated |
gov:domain:{id} |
Yes | none exists (fact 8) | contain |
Structurally, VoteCast is the only candidate. It is not sufficient on its own — see
§7.0, which narrows this further.
7.0 Membership is not the complete authority predicate for a vote
Re-derived at c3782321 and re-verified at 3d401dce. StaticList membership is never
the complete authority predicate for VoteCast in the production composition:
cast_vote(handlers.rs:1812) consults two external predicates before recording a vote, either of which yields403 Forbidden: aMemberStandingChecker— the voter must hold active commons Member standing (handlers.rs:1850) — and aSuspensionChecker— the voter must not be suspended (handlers.rs:1864; type and contract atapps/governance/src/http/configure.rs:374–388). Production wires the latter toCoopManager::is_member_suspended(icn-gateway/src/server.rs:1904–1908, installed at:1947).Revision 3 correction. Revision 2 cited this gate at
handlers.rs:875. That line is the proposer suspension gate on proposal submission, not the vote gate; the file is untouched by #2583, so this was a mis-citation in revision 2 rather than drift. The substance is unchanged and in fact stronger than revision 2 claimed: vote casting carries two external predicates, not one, and neither is reproducible on a receiving node.close_proposalrevalidates active commons Member standing for every voter and excludes those who lost it (actor.rs:2149–2189, driven by handler-computedeligible_voters).GovernanceContextValidationError(configure.rs:183) states outright that production deployments must wireMemberStandingChecker,SuspensionChecker, aMembershipResolverand aMandateGate, because "unresolved standing must not be treated as standing."
Critically, a suspended member remains in the domain's StaticList. That is precisely
the configuration icn-gateway/tests/e2e_close_time_revalidation.rs constructs: a
three-member StaticList domain where Bob's commons standing flips to Suspended while his
StaticList entry is untouched, and his vote is then excluded at resolution. The file states
the principle directly — "standing is a continuous predicate across the full decision arc,
not a one-time entry gate."
Consequence for this design. A suspended-but-listed DID would pass every check in
§5.6: the signature verifies, the domain resolves, the source is StaticList, the
membership_hash matches (they are still listed), and author ∈ members. The replication
path would therefore apply a vote that the HTTP path refuses with 403 — a bypass of a
live production gate, reachable by any suspended member who publishes over gossip instead
of over HTTP.
Why close-time revalidation does not rescue it. It filters the effective tally, so
the final outcome is protected on the closing node — but (a) the vote is stored and
observable before close; (b) revalidation is handler-driven and fails open when the checker
is unwired (configure.rs:552–570 says so explicitly for the resolver case); (c) it does
not make stored state converge, so nodes hold different vote sets; and (d) relying on
downstream filtering to neutralize a write the ingress already believed illegitimate is the
snapshot-only posture that revalidation was introduced to replace. An ingress must not
knowingly write state it cannot justify.
Why the predicate cannot be checked on the receiver. It lives in commons/coop state,
which is not governance state, is not reachable from GovernanceActor at all (the checkers
are HTTP-layer closures the actor never sees), and whose own replication is the
unauthenticated class tracked by #2441. Verifying it on a receiving node would mean
resolving authority against untrusted, non-deterministic state — manufacturing false
determinism, and reintroducing the circularity I9 forbids.
Binding a standing_hash into the envelope would be actively wrong for the same
reason: the receiver would compare it against commons state that is itself unauthenticated.
7.0.1 The resulting v1 authority boundary
v1 boundary =
StaticListmembership AND no external authority predicate applies on the receiving node.Not "StaticList-only". A composition that wires a
SuspensionChecker(or any standing, capability, charter or mandate gate) over vote casting stays contained, because the receiver cannot deterministically evaluate that predicate.
Since the production gateway composition always wires SuspensionChecker, the honest
statement is: authenticated VoteCast application is not restorable in the production
composition in v1. It is restorable only where no external predicate is wired, and the
ingress slice must detect which composition it is in and fail closed by default — an
unwired checker must read as "predicate unresolved," never as "predicate satisfied," per
configure.rs:183.
Unblocking production needs a deterministic, authenticated standing source. That is squarely #2441's territory (authenticated institutional-state replication) and is not in scope here. This is the second place where #2469 and #2441 turn out to be genuinely coupled — the first being the shared quarantine machinery (§11).
7.0.2 A node can only sign for itself — the custody boundary
Derived while wiring slice 3, at 6754d30c.
The envelope's author is the acting principal, and SignedGovernanceOp::sign refuses
any key that does not derive it (replication.rs:308). A GovernanceActor holds exactly one
key: the node's own identity key, extracted from identity_bundle.keypair()
(icn-core/src/supervisor/lifecycle.rs:782).
But GovernanceCommand::CastVote { proposal_id, voter, choice, comment }
(apps/governance/src/actor.rs:2073) takes voter as a parameter, and the actor never
constrains it to self.did. In the gateway composition many members vote through one node,
which holds none of their key material.
A node can emit a signed operation only when it is the acting principal. Gateway-hosted votes are unsignable in v1 — not a gap to patch, a custody fact. Signing a member's vote with the node's key would assert an authorship that does not exist, which is precisely the forgery this envelope exists to prevent.
This is additive to §7.0.1, not a restatement, and it bites at the opposite end of the pipe. §7.0.1 says a receiver cannot evaluate the standing predicate, so it must not apply. This says a sender cannot produce the envelope at all. Either one alone is enough to keep production contained; together they mean the gateway path is contained twice over.
Consequence for slice 7. Restoring VoteCast application does not, by itself, make
gateway deployments converge, because those nodes emit nothing to converge on. A deployment
only produces signed votes where the voter's own daemon holds the voter's key. Any plan that
assumes slice 7 restores replication for the gateway composition is wrong on both counts.
Slice 3 therefore falls back to the legacy payload whenever the key does not belong to the
acting principal, and pins that in signed_governance_emission.rs
(a_vote_cast_for_another_member_is_never_signed).
7.0.3 Per-federation governance topics are never created
Also derived at 6754d30c, and pre-existing — unchanged by slice 3.
publish_federation_if_scoped (actor.rs:3269) publishes to
federation:governance:<fed_id>. The only federation governance topic anything creates or
subscribes is the root federation:governance
(icn-core/src/supervisor/init_gossip.rs:325). TopicAutoCreationPolicy defaults to
Reject (icn-gossip/src/types.rs:617) and nothing in production calls
set_topic_auto_creation_policy, so the per-federation publish is refused — and
publish_federation_if_scoped swallows the error with a warn!.
Federation-scoped governance therefore never reaches the wire in the default
configuration, signed or legacy, before slice 3 or after it. Verified empirically: a
federation-scoped vote produces three entries on governance:proposal and zero on
federation:governance:<fed_id>.
The emission policy still covers the route by construction — publish and
publish_to_topic share one encoder — which the suite proves by declaring the topic first.
Wiring the topic itself is a topic-lifecycle change outside #2469; the current behaviour is
pinned by a_per_federation_topic_is_never_created_in_the_default_configuration so a later
fix cannot land silently.
7.1 Why DelegationRevoked is excluded despite being safe
Revocation is monotonic, idempotent, order-independent, and authorized by a principal named
in state the receiver already holds — it is genuinely safe. But it can only act on a
delegation that exists locally, and DelegationCreated is contained, so on a remote node it
is a guaranteed no-op. Shipping a capability with no reachable effect adds verification
surface for nothing. It becomes useful the moment DelegationCreated is restorable.
7.2 Why ProposalOpened / ProposalClosed / ProposalCancelled are excluded
Membership is not the right authority for choosing an outcome. ProposalClosed carries
outcome and tally as attacker-supplied fields; accepting it on membership alone lets
any single member declare any result, which is strictly worse than the current containment.
The correct authority is the tally/state transition itself — which entangles with the
GovernanceProofV2 signer-authority model that #2469 lists as a non-goal. ProposalOpened
is the force-reset half of the T6 finalization bypass and must not return before the
lifecycle guard exists.
7.3 Honest scope of what v1 delivers
A replicated VoteCast requires the referenced proposal to be present locally, so that step
5's domain check can confirm the vote belongs to domain_id. Since ProposalCreated stays
contained, v1 restores vote convergence only for proposals a node already holds (created
locally or provisioned out of band). That is a narrow capability. It is nonetheless the
right first slice: it is the smallest change that exercises the entire chain — sign → gossip
→ relay → verify → authority → apply → converge — which is exactly the positive-convergence
evidence #2469 demands, and it establishes the pattern without inventing unsafe semantics
for the harder variants.
8. Collision semantics — corrected
Revision 1 proposed first-writer-wins. That is arrival-order dependent and non-convergent (T11): node A sees Alice's object first, node B sees Bob's colliding object first, and the two permanently disagree on the owner. It is withdrawn.
Since no identifier is bound to its creator (fact 12), the options for a colliding
ProposalId / DelegationId are:
| Rule | Deterministic? | Safe? |
|---|---|---|
| First-writer-wins | No — arrival-order | — |
Deterministic tiebreak (e.g. highest op_id wins) |
Yes | No — lets any member displace another's proposal content by grinding op_id; votes keyed by (pid, voter) survive and silently re-attach to the substituted proposal |
| Reject on existing-with-different-author | No — arrival-order | — |
Derived identifiers — ProposalId = H(proposer ‖ domain ‖ nonce ‖ content) |
Yes | Yes — collision becomes impossible by construction |
Derived identifiers are the correct fix, and they are out of scope for #2469: identifier construction reaches HTTP routes, RPC, storage keys, receipts, proofs and the test suite.
Per the standing instruction, the affected mutation class stays contained rather than
receiving invented conflict semantics. ProposalCreated, DelegationCreated,
DomainCreated and DomainUpdated remain refused. A follow-up issue should give governance
objects creator-derived identifiers; that unlocks them.
VoteCast needs none of this. vote_key(proposal_id, voter) already embeds the voter
DID, and step 5 requires vote.voter == author, which step 3 authenticated. Alice can write
only gov:vote:{pid}:{alice}. Cross-author collision on a vote key is impossible by
construction — the one place the existing data model already does the right thing.
9. Key rotation / revocation
Because the DID is the key (fact 7), an op signed by DID D verifies forever — so rotation and revocation are invisible at the signature layer.
DidDocument exists (icn-identity/src/multi_device.rs:19) with a DidDocumentCache
(sync.rs:68), and revocation.rs / revocation_store.rs exist, but the supervisor wires
revocation only for RPC tokens (init_rpc.rs:57–63), not identities.
v1 treats rotation and revocation as membership changes, not signature-layer changes.
A removed or rotated-out member fails the step 10 membership check even though the signature
still verifies. This keeps the signature layer lookup-free (I9) and puts revocation where
authority already lives. Because membership_hash is bound into the signature, an op
authored against a stale member set is rejected at step 9 — membership edits invalidate
in-flight ops by construction, which is the desired fail-closed direction.
Accepting an op signed by a key valid at authoring time but rotated since requires a trusted time source and resolvable key history. Deferred to its own issue.
10. Replay / order / conflict semantics — corrected
Revision 1 proposed rejecting seq <= last_seen. That is unsafe and is withdrawn.
10.1 The failure it would have caused
Alice authors seq=10 (vote on proposal P) and seq=11 (vote on proposal Q). Node B receives 11 then 10. A watermark gate rejects 10 as stale, so B permanently loses Alice's vote on P while A has it — T12, violating I12. Gossip guarantees no delivery order, and anti-entropy can deliver an op arbitrarily late.
10.2 The five candidate mechanisms
| Mechanism | Verdict |
|---|---|
op_id-only durable dedup |
Necessary and sufficient for replay. Order-independent; never discards an unseen op. Does not by itself resolve same-key conflict. |
seq + sliding replay window |
❌ A window is a bounded watermark. Gossip delivery delay is unbounded — anti-entropy may deliver hours later — so any window drops legitimate ops. |
seq + gap quarantine + contiguous-prefix watermark |
❌ The most plausible-looking option and the trap: it requires contiguity, so a permanently dropped op wedges that author's entire stream forever, and the quarantine grows unboundedly under a trivial attack (author high sequence numbers, never send the gaps). |
Hash chaining (prev) |
❌ Same wedge as above — a missing link blocks every descendant — and it forces an author's ops to be strictly serial, which they are not. This is why prev is removed from the envelope. |
Drop seq from acceptance entirely; rely on state/lifecycle/CAS |
✅ Correct for acceptance. Insufficient alone for same-key conflict where state is non-monotonic — and vote state is non-monotonic (For ↔ Against, fact 15). |
10.3 The chosen design
seq has zero role in acceptance. It is only a comparator, scoped to a key.
- Replay gate — the only acceptance gate. A durable set of applied
op_ids. Reject iffop_idwas already applied. Order-independent; an op arriving late is applied normally. No watermark, no contiguity requirement, no gap quarantine. Gaps are normal and permanent. - Same-key conflict — deterministic comparator. When a key already holds a value, apply
the incoming op iff
(incoming.seq, incoming.op_id) > (stored.seq, stored.op_id)lexicographically.seqcarries the author's intent order (a later vote beats an earlier one);op_idbreaks ties deterministically.
This converges under any delivery order. Worked through the §10.1 scenario, and through Alice changing her vote (seq=10 For, seq=11 Against):
| Node A (10 then 11) | Node B (11 then 10) | |
|---|---|---|
| first op | apply 10 → For | apply 11 → Against |
| second op | 11 > 10 → apply → Against | 10 < 11 → no-op → Against |
Both converge to Against, and op 10 is never rejected — it is accepted and then loses the comparator, which is the distinction that makes §10.1 safe.
seq must be per-(author, domain) monotonic at the origin. The durable monotonic counter
pattern from #2510 (init_send_callback.rs, fail-closed on persistence failure) is the
precedent to reuse when slice 3 wires emission.
- Cross-author ordering is deliberately not provided. That is consensus, which #2469 excludes. Safety without it comes from I7 (lifecycle monotonicity) and §8 (no cross-author key collisions in the restorable set).
- Wall-clock fields order nothing.
entry.timestamp,vote.timestampandupdated_at-style LWW are attacker-supplied and are not authorities.
11. Relationship to #2441
Same defect class, different authority roots.
- #2441's proposed
ReplicationPolicyname-collides withicn-kernel-api/src/state.rs:29. Whichever issue moves first should rename. - Governance can close without #2441, and vice versa.
- Do not build the shared seam yet. The pieces most likely to generalize are the
quarantine store, the canonical-encoding helper and the
op_iddedup set — not the authority model, which is genuinely per-class.
12. RED tests required before implementation
Exercised through production wiring (init_governance_actor + GossipActor::handle_message),
extending apps/governance/tests/fp02_governance_replication_containment.rs. Slice 1's own
unit tests are listed separately in §13.
Content / author / domain binding
- Forged
author, no valid signature → rejected before any state write. - Valid signature, one byte of
op_bytesmutated → rejected. - Valid envelope re-wrapped under a different
entry.author→ still applied on its own merits (provesentry.authoris not an authority) and rejected if the inner signature fails. entry.hashnot matchingentry.data→ rejected. Already covered on main by #2583'sicn-gossip/tests/entry_hash_integrity.rs; retained here as the ingress-level assertion that the envelope path inherits that guarantee rather than re-implementing it.- Envelope validly signed for domain A, replayed naming domain B → rejected.
Authority
6. Valid signature by a non-member of the target domain → rejected.
7. vote.voter ≠ envelope author → rejected.
7b. Cross-domain tally injection: a member of domain D signs a VoteCast for a
proposal belonging to domain E, declaring domain_id = D → rejected at step 8. Every
other check passes, so this fails only if the proposal anchor is checked.
8. Unknown domain_id → quarantined, not applied, no network fetch.
8b. Known domain_id but unknown vote.proposal_id → quarantined, not applied.
9. Local domain is TrustThreshold → not applied (§5.5), and this is asserted as
deliberate, not incidental.
10. membership_hash computed against a stale member set → rejected.
Replay / order — the corrected semantics
11. Byte-identical replay of an applied op → no second application.
12. Out-of-order delivery: ops seq=10 (proposal P) and seq=11 (proposal Q) delivered 11
then 10 → BOTH applied. The direct regression test for T12; must fail against a
watermark implementation.
13. Same-key conflict delivered in both orders (10 For / 11 Against, and 11 then 10) → both
nodes converge to Against.
14. An op with seq far below the author's highest seen, on an untouched key → applied.
Lifecycle
15. ProposalOpened on a terminal proposal → rejected even when fully authorized (T6).
16. Reopen→close finalization bypass → outcome unchanged.
Standing predicate — §7.0
16b. A suspended-but-listed member's validly signed VoteCast → not applied. Build the
e2e_close_time_revalidation.rs shape: DID stays in the StaticList, commons standing
flips to Suspended. Signature, domain, membership_hash and membership all pass, so
this fails unless the standing predicate is consulted.
16c. A composition with a SuspensionChecker wired → VoteCast not applied at all, and
asserted as deliberate rather than incidental.
16d. An unwired checker reads as "unresolved", not "satisfied" — the ingress must fail
closed, matching configure.rs:183.
Containment retained
17. A validly signed, authority-bearing ProposalCreated / DelegationCreated /
DomainCreated → still not applied (§7, §8), asserted as deliberate.
Positive convergence — the test that makes this a fix rather than another block
18. A legitimately signed, authority-bearing VoteCast from a second node, relayed through
a third, is applied, and the nodes converge. Must exercise a real relay hop so
fact 6 is actually covered.
Migration 19. An unsigned legacy payload is refused while a signed one is applied.
13. Implementation slices, in order
| Slice | Content | Restores state application? |
|---|---|---|
| 1 | SignedGovernanceOp type, magic + version, canonical encoding, membership_hash, sign/verify (refusing an author/key mismatch), derived op_id. Library only, unwired. |
No |
| 2 | ✅ DONE — #2583 (3d401dce). Re-derive entry.hash on receipt in gossip. Generic, separable. |
No |
| 3 | ✅ DONE. Emit signed envelopes from the governance publish path (durable per-(author,domain) seq, #2510 pattern); recognise both shapes; apply nothing. |
No |
| 4 | Bounded quarantine store + steward release valve. | No |
| 5 | Durable op_id applied-set + the §10.3 comparator in the state store. |
No (local semantics only) |
| 6 | Lifecycle monotonicity guard, enforced unconditionally. | No |
| 7 | Verify + authority + apply, lifting containment for VoteCast only, and only in a composition with no external standing predicate (§7.0.1). Must fail closed when a SuspensionChecker or equivalent is wired. Containment tests updated, not deleted. |
Yes, narrowly |
Slices 1–6 restore nothing. Slice 7 is the only one that lifts containment, and does so in the same change that proves positive convergence (test 18).
Slice 3, as built. publish (actor.rs:3128) and publish_to_topic (:3136) are the
only two functions that hand governance bytes to gossip, and both route through one
encode_for_emission seam, so federation cannot drift onto a different policy. Corrected
counts: 12 publish call sites (not ~10), and 1 publish_to_topic call site (not
~9) — publish_federation_if_scoped, which itself has 7 callers.
Eligibility falls back to the legacy payload at every step (kind not replicable, no acting
principal, domain unresolvable, membership not StaticList, no key, key not the
principal's); commitment fails closed, because a signed operation carrying a sequence
that was never persisted is an ambiguity no receiver can detect. Signing is not restricted
to V1_RESTORABLE_OP_KINDS — ProposalCreated, DelegationCreated and DelegationRevoked
are signed where their principal is the node — but eligible is not restorable, and
ingress still applies nothing.
Recognition reads entry.get_data(), not entry.data: publish compresses entries above a
size threshold after hashing, so the raw field is not the payload and a prefix check over
it would misclassify every large entry. This matches computed_content_hash, which resolves
the logical payload for the same reason.
Slice 1 unit tests (this change):
sign→verify round-trip; tampered op_bytes; tampered domain_id; tampered author;
tampered seq; tampered authority; tampered op_kind; wrong-signer rejection; unknown
version rejected; bad magic rejected; truncated frame rejected; op_id determinism and
sensitivity to every signed field; membership_hash set-order and duplicate invariance,
domain separation, and sensitivity to member changes; encode→decode round-trip;
op_kind/payload disagreement rejected.
14. Is the field set stable enough to freeze?
Yes. Each previously open question either has a decision or has been shown not to touch the envelope:
| Question | Resolution | Touches field set? |
|---|---|---|
| O1 membership determinism | StaticList-only (§5.5) | Yes → ReplicationAuthority::StaticMembership { membership_hash } |
| Replay under out-of-order delivery | op_id gate + seq comparator (§10.3) |
Yes → keeps seq, removes prev |
| Collision arbitration | Contain the affected classes (§8) | No — no field needed |
| Authority snapshot scope | Membership only, not whole config (§5.4) | Yes → removes domain_config_hash |
| First restorable variant set | VoteCast only (§7) |
No — op_kind already carries it |
| Standing / suspension predicate (§7.0) | Ingress policy; fail closed where it applies | No — see below |
ProposalClosed authority (old O2) |
Contained; deferred with the proof-signer model | No |
| Key rotation / revocation | Membership-mediated in v1 (§9) | No |
| Quarantine bounds (old O3) | Slice 4 detail | No |
The remaining unknowns — derived identifiers, tally-rooted lifecycle authority, deterministic
membership witnesses — all resolve into either a new ReplicationAuthority variant or a
version bump, which is precisely what version and the one-variant enum exist to absorb.
14.1 Why the standing finding does not move the field set
The §7.0 discovery narrows what v1 may apply, not what v1 must sign. The envelope already binds author, domain, membership snapshot and content; the suspension predicate is evaluated against receiver-local state at ingress, exactly like the domain lookup and the membership check, none of which are carried on the wire either.
Adding a standing_hash was considered and rejected: the receiver would have to compare it
against commons state that is itself unauthenticated (#2441), so the field would create the
appearance of a deterministic check over non-deterministic data — worse than having no
field, because it would look like the hole was closed.
Field set frozen as in §5.1. Slice 1 stands; the correction lands in §7.0/§7.0.1 and in slice 7's policy.