Countersigning
Two or more agents committing the same entry atomically, each to their own source chain. Every type and signature below is taken from the shipped hdk 0.7.0 and holochain_integrity_types 0.7.0 sources.
Countersigning is still unstable in 0.7
It sits behind a Cargo feature that is off by default:
# in your coordinator zome
hdk = { version = "=0.7.0", features = ["unstable-countersigning"] }
The conductor must be built with it too. holochain 0.7.0 declares:
unstable-countersigning = [
"hdk/unstable-countersigning",
"holochain_zome_types/unstable-countersigning",
"holochain_conductor_api/unstable-countersigning",
]
Default features on holochain 0.7.0 are encryption, schema, wasmer-sys-cranelift. Countersigning is not among them.
What this means in practice. A stock conductor binary, including the one holonix installs and the one Kangaroo bundles, does not have countersigning compiled in. If your hApp needs it you are building and shipping your own conductor. Treat that as an architectural commitment, not a feature flag you flip late. Design an alternative first and reach for countersigning only when nothing else gives you the atomicity you need.
The session shape
A countersigning session has one initiator and N signers. It runs in three beats.
- The initiator builds a
PreflightRequestand distributes it to every signer, usually by remote call. - Each signer calls
accept_countersigning_preflight_request, which freezes that signer’s source chain until the session ends. Each returns aPreflightRequestAcceptanceto the initiator. - With every acceptance in hand, the initiator builds the entry and everyone commits it.
The chain freeze in step 2 is the whole point and the whole danger. Between accepting and resolving, that agent can commit nothing else.
PreflightRequest
#![allow(unused)]
fn main() {
pub struct PreflightRequest {
/// Hash of the app entry as if it were not countersigned.
/// The final entry hash will include the countersigning session.
pub app_entry_hash: EntryHash,
/// The agents participating in this session.
pub signing_agents: CounterSigningAgents,
/// Optional additional M of N signers.
pub optional_signing_agents: CounterSigningAgents,
/// The M in M of N. Must be strictly greater than N / 2 and not larger than N.
pub minimum_optional_signing_agents: u8,
/// If true, the first signing agent (index 0) acts as an enzyme.
pub enzymatic: bool,
/// Bounds the session in time. All session actions share one timestamp.
pub session_times: CounterSigningSessionTimes,
/// Action information shared by all agents. Depends on the action type.
pub action_base: ActionBase,
/// Arbitrary application bytes carried through the preflight.
pub preflight_bytes: PreflightBytes,
}
}
Build it with the fallible constructor, never by struct literal. try_new runs check_integrity() for you and returns Result<Self, CounterSigningError>:
#![allow(unused)]
fn main() {
PreflightRequest::try_new(
app_entry_hash,
signing_agents,
optional_signing_agents,
minimum_optional_signing_agents,
enzymatic,
session_times,
action_base,
preflight_bytes,
)?
}
Two constraints the constructor enforces and that are easy to get wrong:
- If there are optional signers, M must be the majority of N: strictly greater than
N / 2, and not larger thanN. - If there are optional signers, the enzyme must be used, and it must be the first agent in both
signing_agentsandoptional_signing_agents.
Session times
#![allow(unused)]
fn main() {
pub fn session_times_from_millis(ms: u64) -> ExternResult<CounterSigningSessionTimes>
}
Starts the session at the initiator’s “now” and ends it ms milliseconds later. Every signer checks these times while accepting, so:
- System clocks across participants must be roughly aligned.
- The window must comfortably exceed the ambient network round trip for the whole group.
Too short and honest signers get UnacceptableFutureStart or simply miss the window. Too long and every participant’s chain stays frozen for that long.
Accepting
#![allow(unused)]
fn main() {
pub fn accept_countersigning_preflight_request(
preflight_request: PreflightRequest,
) -> ExternResult<PreflightRequestAcceptance>
}
This must be called by every signer. How you distribute the request is up to you; concurrent remote calls are the simplest mechanism that fits inside a session timeout.
Handle all five outcomes:
#![allow(unused)]
fn main() {
pub enum PreflightRequestAcceptance {
/// Accepted. Send the response back to the initiator.
Accepted(PreflightResponse),
/// Start time is too far in the future for this agent.
UnacceptableFutureStart,
/// The request does not include this agent.
UnacceptableAgentNotFound,
/// Not checked: another session is already in progress on this chain.
AnotherSessionIsInProgress,
/// Failed an integrity check.
Invalid(String),
}
}
AnotherSessionIsInProgress is the one people forget. An agent can only be in one countersigning session at a time, so any design where a single agent is a hot spot, for example one marketplace operator countersigning every trade, will serialize and then fail under load.
Failure surface
ZomeCallResponse carries a dedicated variant for this:
#![allow(unused)]
fn main() {
ZomeCallResponse::CountersigningSession(String)
}
Match it explicitly rather than folding it into a wildcard, since it means “the session failed to start”, which is operationally different from a network error.
Design checklist before you commit to countersigning
- Can the invariant be expressed as validation on two independent entries instead? That needs no feature flag and no chain freeze.
- Are you prepared to build and distribute a custom conductor with
unstable-countersigningenabled? - Is any single agent a participant in a high proportion of sessions? If so,
AnotherSessionIsInProgressis your throughput ceiling. - Is the session window long enough for your worst realistic latency and short enough that a stalled peer does not freeze chains for minutes?
- Does every signer handle all five
PreflightRequestAcceptancevariants, including the two that mean “retry later” rather than “fail”? - If you use optional signers, does M satisfy the strict majority rule, and is the enzyme first in both agent lists?
Related
access-control.mdfor the cap grants the remote calls that distribute preflight requests will need.patterns.mdfor ordinary entry commit and validation, which is where most “atomic” requirements should land instead.