Auto-Follow Group Membership
Event-driven context auto-join for group members, with per-member opt-in
Scope
This page covers context auto-join — the contexts: true path. The handler
reacts to ContextRegistered / AutoFollowSet ops and emits
JoinContextRequest. It does not perform member admission and does
not act on the subgroups: true flag (see
TEE Fleet Integration below). For how a fleet node actually
gains subgroup membership, see TEE Fleet HA.
Problem
Group membership in Calimero is explicit at every level. When a new context is registered in
a group, existing members do not automatically replicate it — each must call
join_context explicitly. When a subgroup is nested under a parent, the parent's
members are not automatically admitted to the child either (subgroup membership is independent).
This works for human-driven clients but breaks two concrete scenarios:
- TEE fleet HA. A fleet node admitted via
fleet-joinjoins only the contexts that existed at admission time. Later contexts are invisible until something else triggers a join, which — before auto-follow — was polling by the sidecar. - Regular members observing a growing group. A member who joined when the group had 3 contexts sees nothing when a 4th appears, unless the client polls or the user reacts manually.
Design
Each GroupMember gains a pair of opt-in flags:
AutoFollowFlags {
contexts: bool, // auto-join new ContextRegistered — default true
subgroups: bool, // auto-admit into newly-nested subgroups — default false
}
Defaults (as of #2422): contexts: true, subgroups: false.
A new member added via add_group_member auto-follows new contexts in the group by
default; subgroup auto-admit stays opt-in. ReadOnlyTee members get both flags set to
true automatically. The default lives in a single
explicit impl Default for AutoFollowFlags in
crates/store/src/key/group/mod.rs, so both the borsh-legacy decode fallback and
.unwrap_or_default() sites pick it up.
The subgroups: true flag is currently a no-op in the auto-follow path. The handler
implements contexts: true only. It reads the subgroups field off
AutoFollowSet and discards it (subgroup auto-follow is "implemented per-role in a
follow-up" per the module docs). So although a ReadOnlyTee member carries
subgroups: true, that flag drives no admission today. How a fleet node actually obtains
subgroup access is covered in TEE Fleet HA and summarised in
TEE Fleet Integration below.
Flags are toggled by the governance op GroupOp::MemberSetAutoFollow with
admin-or-self authorization:
- A group admin can toggle flags for any member.
- A member can toggle their own flags.
Propagation
The handler subscribes to an in-process op-apply event channel (module
context::op_events). When a relevant op is applied to local state, an
OpEvent is broadcast and the handler emits the corresponding join op. Every emitted
op is itself a DAG op, so offline catch-up comes for free via DAG replay — no separate
reconcile loop.
Authorizer-aware apply path
The new apply_local_signed_group_op_at_cut function accepts an
&dyn AtCutAuthorizer alongside the op and threads it through
apply_group_op_mutations via GroupApplyCtx::new_with_apply_auth and
PermissionChecker::with_apply_auth. Namespace envelopes forward the enclosing
namespace op's causal cut and authorizer via
NamespaceGovernance::apply_group_op_inner, so group ops carried in namespace
envelopes are authorized at the correct cut. The original apply_local_signed_group_op is
now a shim that calls the new variant with the inert LIVE_FALLBACK_AUTHORIZER,
keeping all existing callers (tests, replay) unchanged.
PermissionChecker::is_admin and is_authorized_with_capability now
query the projection at the op's causal cut first via is_admin_at_cut /
is_admin_or_capability_at_cut. When the authorizer returns
Some(verdict), that verdict is used directly and the live
MembershipRepository is not consulted. The live store is queried only when the
authorizer returns None (no apply-auth context, empty parents, or an incomplete
fold). The former shadow_admin helper — which compared the projection verdict to
the live verdict and emitted unified_projection_divergence warnings on mismatch —
has been deleted along with the associated shadow block in
is_authorized_with_capability.
In apply_group_op_mutations (called from
NamespaceGovernance::apply_group_op_inner), the parents argument now
uses signed_group_op.parent_op_hashes instead of self.parents. On
the normal apply path the two are equal, but during
retry_encrypted_ops_for_group multiple buffered group candidates are re-applied
within a single KeyDelivery apply; using self.parents would judge
every replayed candidate at the KeyDelivery's cut rather than each candidate's
own cut.
Flow
0. (NEW, #2422) A new member is added to the group via
GroupOp::MemberAdded / GroupOp::MemberJoinedViaTeeAttestation /
RootOp::MemberJoined (open-subgroup self-join).
└─ Apply path writes a fresh GroupMember row with default flags
(contexts: true, subgroups: false).
└─ build_auto_follow_set_if_enabled synthesises
OpEvent::AutoFollowSet { member, contexts: true, subgroups: false }
so the on-join backfill cascade fires without requiring an
explicit MemberSetAutoFollow op. This closes the Ronit/Fran
regression where a joiner saw no pre-existing contexts until an
admin manually flipped a flag.
1. Admin or member publishes MemberSetAutoFollow { target, contexts, subgroups }.
└─ Authorized by admin-or-self check in apply_group_op_mutations.
└─ Store updates GroupMemberValue.auto_follow.
└─ op_events::notify(OpEvent::AutoFollowSet { ... }).
2. Auto-follow handler (spawned once from ContextManager::started) observes
AutoFollowSet { member = self, contexts: true } and backfills: enumerate
up to BACKFILL_LIMIT contexts in the group, emit JoinContext for each.
Backfill is rate-limited to DEFAULT_BURST / DEFAULT_PER. Idempotent on
already-joined contexts, so steps 0 + 1 firing for the same member is
safe (e.g. TEE fleet-join: synthesised from MemberJoinedViaTeeAttestation
then explicit MemberSetAutoFollow from fleet_join.rs).
3. Later, anyone registers a new context in the group.
└─ GroupOp::ContextRegistered applied.
└─ op_events::notify(OpEvent::ContextRegistered { group, context }).
└─ Handler checks: is self a member with auto_follow.contexts = true?
└─ If yes, emit JoinContext — same rate limit.
4. Later, an admin creates a subgroup under this group.
└─ RootOp::GroupCreated { group_id, parent_id } applied on namespace DAG
(atomic create+nest — strict-tree invariant).
└─ op_events::notify(OpEvent::SubgroupCreated { parent, child }).
└─ The auto-follow handler IGNORES this event today (the SubgroupCreated /
AutoFollowSet { subgroups: true } variants fall through the catch-all
match arm). Subgroup membership is handled elsewhere — see TEE Fleet HA.
4b. Admin moves an existing subgroup to a new parent.
└─ RootOp::GroupReparented { child, new_parent } applied.
└─ op_events::notify(OpEvent::SubgroupReparented
{ old_parent, new_parent, child }).
TEE Fleet Integration
A TEE fleet node calls POST /admin-api/tee/fleet-join. The handler in
server::admin::handlers::tee::fleet_join:
- Generates a TDX attestation quote bound to the node's namespace identity pubkey.
- Broadcasts
TeeAttestationAnnounceon the namespace topic. - Polls for admission (up to 30 s) by calling
list_group_contexts. Once the verifier'sMemberJoinedViaTeeAttestationop has propagated, the list succeeds. - Joins all existing contexts in the group.
- Publishes
MemberSetAutoFollow { target: self, contexts: true, subgroups: true }, signed by the node's own namespace-identity key. The admin-or-self rule is satisfied via the self path — the admitting verifier can't do this on the member's behalf because it usually lacks both admin authority and the member's signing key.
From this point, every new context in the group is auto-joined by the core handler (the
contexts: true path described on this page). The mero-tee sidecar's
per-group context-polling loop becomes redundant.
The subgroups: true flag published here is, today, a no-op. The auto-follow
handler implements contexts: true only — it does not act on subgroups: true
and does not perform member admission. Publishing subgroups: true at fleet-join
therefore does not cause the node to auto-join subgroups. The flag is set in anticipation of
the per-role subgroup follow-up, but it drives no admission in the current tree.
Subgroup access for a ReadOnlyTee fleet node comes from two other mechanisms,
not from auto-follow:
- Open subgroups. Inherited membership plus the namespace key the node already holds — no separate admission step is required.
- Restricted subgroups. A separate
tee_subgroup_admitsubscriber (PR #2772) in which an existing key-holder admits the node in response toSubgroupCreated/TeeMemberAdmittedevents. This is outside the auto-follow path entirely.
The full fleet/subgroup admission story is documented in TEE Fleet HA.
Policy scope — namespace, not per-group. The canonical TeeAdmissionPolicy lives
on the namespace root only. read_tee_admission_policy in group_store::tee
resolves its argument to the namespace root before reading, and both the write handler
(handlers::set_tee_admission_policy) and the apply path in
group_store::apply_group_op_mutations refuse a TeeAdmissionPolicySet
targeting a subgroup. Subgroup policy bytes in any legacy op logs are ignored. Admission — including
how this namespace-scoped policy is enforced — is covered in depth in
TEE Fleet HA.
Rate Limit & Backpressure
The handler runs behind a token-bucket limiter. Defaults:
DEFAULT_BURST = 20— tokens available at once.DEFAULT_PER = 60 s— bucket refills fully in this window (one token every 3 s).BACKFILL_LIMIT = 1000— per-flip cap for enumerating existing contexts. Future contexts beyond the cap are picked up event-driven with no additional limit.
Semaphore-closed and subscriber-lagged conditions are both surfaced via warn!. The
authoritative recovery mechanism is always DAG replay: if an event is missed (best-effort
broadcast), the next run of the handler walks the DAG and reconciles state.
Role Resolution in Inherited Paths
Two functions in MembershipRepository determine what role an inherited member
carries when they access a child group or context. Both were tightened as part of
#2422.
enumerate_inherited
For each non-admin inherited candidate the function previously hard-coded
GroupMemberRole::Member. It now calls role_of(&anchor, &candidate)
to retrieve the candidate's actual role at the anchor group, falling back to
Member only if the row is unexpectedly absent. Admin paths continue to resolve to
Admin (unchanged). Callers therefore receive the real anchor role — for example
ReadOnlyTee — rather than a uniform Member.
New regression tests
enumerate_inherited_members_preserves_read_only_tee_role— verifies the enumeration path returnsReadOnlyTeefor an inherited member carrying that role.
Operator Notes
- Observability. Every auto-join emits a structured
info!log line withgroup_idandcontext_id. Failures emitwarn!with the underlying error. No new metrics — the log stream is enough for postmortems. state_hashmismatch warnings. Inapply_local_signed_group_op_at_cut, a mismatch between the op's signedstate_hashand the recomputed current hash previously caused a hard error that dropped the op. It now emits a structuredtracing::warn!(including op variant, expected/actual hashes, nonce, and signer) and proceeds to apply the op. Alocal group op state_hash mismatchwarning is therefore expected under concurrent multi-node governance activity and does not indicate a rejected op; it is staleness telemetry only. The absent-meta bypass (skip hash check when the subgroup meta row does not yet exist, #2848) is preserved unchanged. Additionally, the per-signer nonce-window load and containment check (load_nonce_window) now runs before thestate_hashcomparison block, so a replayed op returnsOk(())early without ever reaching the hash logic and cannot emit a misleading mismatch warning for an op that is actually being discarded.- Membership-path authorization. The
member_joined_open::applygate now evaluates membership-path authorization against the causal-cut projection state viaNamespaceApplyCtx::projection_membership_path, which returnsOption<AtCutMembershipPath>. The livecheck_pathcall is a fallback used only when the projection returnsNone(no apply-auth context, incomplete fold, or default live-only authorizer). The formershadow_membership_pathhelper — which accepted an eagerly-computed live path, compared it against the projection, and emittedunified_projection_divergence/plane=membership-pathwarnings on divergence — has been removed along with all associated shadow-logging. Operators who were monitoring those warnings should note they will no longer appear.
FIND>>>
<< - Observability. Every auto-join emits a structured
info!log line withgroup_idandcontext_id. Failures emitwarn!with the underlying error. No new metrics — the log stream is enough for postmortems. - Membership-path authorization. The
member_joined_open::applygate now evaluates membership-path authorization entirely against the unified projection viaAtCutAuthorizer::membership_path_at_cut. The former live DAG walk (acl_view_at/prefix_walk_membership/MembershipStatus) has been removed; those symbols no longer exist. The livecheck_pathcall is a fallback used only when the projection returnsNone(no apply-auth context, incomplete fold, or default live-only authorizer). Theshadow_membership_pathdivergence-logging helper has also been removed.
FIND>>>
<< - Authorization source.
PermissionChecker::is_adminandis_authorized_with_capabilitynow use the projection-at-cut verdict as authoritative when available. Live store queries are a fallback only when the authorizer returnsNone. The last-admin removal/demotion gates (ensure_not_last_admin_removalandensure_not_last_admin_demotion) likewise resolve against the projection at the op's parent cut viawould_orphan_admins, falling back to live membership only when no apply-auth context is present. Theshadow_last_admindivergence-logging helper and all associatedunified_projection_divergence/plane=last-adminwarnings have been removed now that the projection verdict is authoritative. - Shutdown. Call
auto_follow::shutdown()to abort the handler task and its refill loop. Subsequentspawncalls will start a fresh handler. - Migration.
GroupMemberValuewas extended withauto_followvia a custom Borsh deserializer. Records written under the pre-auto-follow schema are transparently read with default flags, and transparently upgraded on the next write. A partial trailing byte (data corruption) surfaces as a deserialization error instead of being silently defaulted. - Implementing
AtCutAuthorizer. Any custom implementation ofAtCutAuthorizermust now provideis_admin_or_capability_at_cutand the newmembership_path_at_cutmethod. For both, returningNone(asLiveFallbackAuthorizerdoes) is safe and causes the gates to fall back to live store queries. Note the distinction:Option::Nonemeans "defer to live", whileSome(AtCutMembershipPath::None)means "not a member at this cut" and is an authoritative answer.
FIND>>>
<<
member_joined_open::apply gate
now evaluates membership-path authorization entirely against the unified projection via
AtCutAuthorizer::membership_path_at_cut. The former live DAG walk
(acl_view_at / prefix_walk_membership / MembershipStatus)
has been removed; those symbols no longer exist. The live check_path call is a
fallback used only when the projection returns None (no apply-auth context,
incomplete fold, or default live-only authorizer). The
shadow_membership_path divergence-logging helper has also been removed.
AtCutAuthorizer. Any custom implementation of
AtCutAuthorizer must now provide is_admin_or_capability_at_cut and
the new membership_path_at_cut method. For both, returning None (as
LiveFallbackAuthorizer does) is safe and causes the gates to fall back to live
store queries. Note the distinction: Option::None means "defer to live", while
Some(AtCutMembershipPath::None) means "not a member at this cut" and is an
authoritative answer.
Key Files
crates/context/src/op_events.rs— op-apply event channel +OpEventenum.crates/context/src/auto_follow.rs— handler task, rate limiter,spawn/shutdown.crates/context/src/group_store/mod.rs—apply_group_op_mutations(now acceptsparentsandauthorizer) handlesMemberSetAutoFollow;apply_local_signed_group_op_at_cutis the new authorizer-aware public entry point;apply_local_signed_group_opis now a shim forwarding to it with the inertLIVE_FALLBACK_AUTHORIZER.crates/context/src/group_store/membership.rs—set_member_auto_followhelper.crates/context/primitives/src/local_governance/mod.rs— theMemberSetAutoFollowop variant.crates/store/src/key/group/mod.rs—AutoFollowFlagsand the backward-compatibleBorshDeserializeforGroupMemberValue.crates/context/src/handlers/admit_tee_node.rs— TEE admission publishes the op only; flags are set by the admitted node itself.crates/server/src/admin/handlers/tee/fleet_join.rs— after admission, the member publishesMemberSetAutoFollowsigned by self.crates/context/src/group_store/permission.rs(or equivalent) —PermissionCheckergainswith_apply_auth;is_adminandis_authorized_with_capabilityuse the projection-at-cut verdict as the authoritative decision whenAtCutAuthorizerreturnsSome, falling back to live store queries otherwise. Theshadow_adminhelper andunified_projection_divergencelogging have been removed.MembershipPolicynow optionally carries the op's parent cut hashes and anAtCutAuthorizerreference, set via the newwith_apply_authbuilder method.ensure_not_last_admin_removalandensure_not_last_admin_demotionnow delegate to the newwould_orphan_adminshelper, which callsAtCutAuthorizer::is_last_admin_at_cutto read the pre-mutation admin set from the projection as of the op's parent cut. The live membership computation is used only as a fallback when no apply-auth context is present (local pre-checks, cascades, tests) or the fold is incomplete. Theshadow_last_adminhelper and allunified_projection_divergence/plane=last-adminwarnings have been deleted.GroupApplyCtx::newcalls.with_apply_auth(parents, authorizer)on theMembershipPolicyit constructs, activating the projection-at-cut last-admin gate for all group-op apply paths.crates/context/src/authorizer.rs(or equivalent) —AtCutAuthorizertrait gains the requiredis_admin_or_capability_at_cutmethod, theis_last_admin_at_cutmethod, and the new requiredmembership_path_at_cutmethod. All methods must returnNonewhenparentsis empty (the empty-cut contract), so that genesis ops are never falsely rejected.EphemeralProjectionAuthorizerdelegatesis_last_admin_at_cuttoScopeProjections::is_last_admin_at_cutand implementsmembership_path_at_cutby delegating toScopeProjections::membership_path_at_cut, mapping the result to the newAtCutMembershipPathenum (projecting away role/anchor detail). An emptyparentsslice returnsNone(defer to live), consistent with the existingis_admin_at_cutpattern.LiveFallbackAuthorizerimplements all three methods as always-None, keeping all non-apply-auth constructions on the live-fallback path.
Note: the live governance-cut resolver (acl_view_at,MembershipStatus,prefix_walk_membership,MembershipTransition,resolve_membership_from_transitions,heads_equal,MAX_PREFIX_WALK_NODES) has been deleted fromgovernance-store/src/membership/status.rs. Membership authorization is now handled entirely by the unified projection; do not reference these removed symbols.
FIND>>>
<<
crates/context/src/authorizer.rs (or equivalent) — AtCutAuthorizer
trait gains the required is_admin_or_capability_at_cut method, the
is_last_admin_at_cut method, and the new required
membership_path_at_cut method. All methods must return None when
parents is empty (the empty-cut contract), so that genesis ops are never
falsely rejected. EphemeralProjectionAuthorizer delegates
is_last_admin_at_cut to ScopeProjections::is_last_admin_at_cut
and implements membership_path_at_cut by delegating to
ScopeProjections::membership_path_at_cut, mapping the result to the new
AtCutMembershipPath enum (projecting away role/anchor detail). An empty
parents slice returns None (defer to live), consistent with the
existing is_admin_at_cut pattern. LiveFallbackAuthorizer implements
all three methods as always-None, keeping all non-apply-auth constructions on
the live-fallback path.
Re-drive Buffered Ops on GroupCreated & Born-Open Subgroups (#2848 / #2771)
This section documents four related changes that close a permanent-stall window where buffered
encrypted ops for a subgroup could never be applied, and extends RootOp::GroupCreated
to carry subgroup visibility at creation time.
The Stall Window (Motivation)
The only previous trigger for retrying buffered encrypted ops was KeyDelivery. If
KeyDelivery arrived before GroupCreated, the retry failed
immediately because the subgroup's GroupMeta row did not yet exist, and no
subsequent trigger ever re-fired. The buffered ops remained stranded indefinitely. This affected
re-admitted members and any topology where DAG op ordering placed GroupCreated after
the key delivery.
Part A — Re-drive on GroupCreated Apply
After RootOp::GroupCreated is applied, the governance layer now checks whether the
node holds any key for the new subgroup (or the namespace key, for Open subgroups). If a key is
held, it immediately calls the buffered-op retry path for that group — the same path that
KeyDelivery triggers. This closes the stall window regardless of which op arrives
first.
The retry is triggered on both apply entry points:
apply_local_signed_group_op_at_cut— local group-op path.NamespaceGovernance::apply_group_op_inner— namespace envelope path.
Part B — Bypass state_hash Staleness Check When Subgroup Meta Is Absent
Both apply paths now skip the state_hash staleness check entirely when the
subgroup's GroupMeta row does not yet exist, rather than calling
compute_state_hash and failing with GroupNotFoundForHash. This mirrors
the existing zero-hash fast-path and makes the meta-absent case consistent with it:
// Before: compute_state_hash → GroupNotFoundForHash
// After:
if meta_absent || state_hash == ZERO_HASH {
// skip staleness check
}
Part C — Curative Startup Sweep
A one-shot redrive_stranded_ops_sweep runs at node startup (inside
self_purge::run, after subscribing to op_events and before the event
loop). It:
- Iterates every namespace the node holds an identity for.
- Finds groups with buffered ops whose key is already held (the inverse of
groups_awaiting_key). - Re-drives each one via
redrive_encrypted_ops_for_group_counted. - Loops until a full pass applies nothing new (cross-signer convergence), bounded by
MAX_PASSES = 64, with pass-narrowing so only groups that made progress in the previous pass are revisited.
Errors are logged and swallowed; the sweep never blocks startup. Nodes that were stranded before this release will self-heal on the next restart without operator intervention.
TEE-Replica Bootstrap — Seed Root Default Capabilities
seed_bootstrap_admin_if_absent (the KeyDelivery-seed path used by TEE
replicas) seeds the namespace root's default capabilities to
CAN_JOIN_OPEN_SUBGROUPS when that capability key is absent. The seed is gated on
absence so it is idempotent and never clobbers a later admin-authored
DefaultCapabilitiesSet op. Without this, a TEE replica could hold the group key
but still be unable to join Open subgroups because the capability row was never written.
Founder is no longer pinned here (#2474). This path no longer pins the
KeyDelivery signer as admin/owner. It now writes a non-authoritative
placeholder (zero-key admin_identity) plus a Member row for the
deliverer, alongside the default-capabilities bootstrap above. The real founding admin is
derived authoritatively from the synced DAG genesis op
(RootOp::NamespaceCreated { founder }), which overwrites the placeholder on apply —
replacing the previous trust-on-first-KeyDelivery behavior.
Pre-existing stranded replicas need either a restart (triggering the startup
sweep in Part C) or a gossiped DefaultCapabilitiesSet op from an admin to acquire
the capability row.
Born-Open Atomic Subgroup Create (RootOp::GroupCreated wire change)
RootOp::GroupCreated gains a boolean restricted field
(Borsh wire break — nodes must reset). The apply handler writes the subgroup's
visibility key via CapabilitiesRepository::set_subgroup_visibility during apply,
before OpEvent::SubgroupCreated is queued, and only on first apply (gated on
has_subgroup_visibility absence to avoid clobbering a later
SubgroupVisibilitySet flip):
restricted: false— subgroup is born Open;tee_subgroup_admitskips it and no transient directReadOnlyTeerow is written.restricted: true— legacy behavior; subgroup is born Restricted. This is the default and preserves wire compatibility for any tooling that constructs the op directly.
The op-adapter projection now reads restricted from the live op instead of
hardcoding true.
API surface changes:
CreateGroupRequestgains arestrictedfield (defaulttrue).- The
create-group-in-namespaceREST endpoint accepts an optionalvisibilityfield ("open"or"restricted", default"restricted").
Operator Checklist
- Wire break. The
RootOp::GroupCreatedBorsh shape has changed. All nodes in a namespace must reset before the new binary is deployed; mixed-version clusters will fail to deserialize the op. - Stranded replicas. TEE replicas admitted before this release may lack the
root default-capability row. A restart or a gossiped
DefaultCapabilitiesSetfrom an admin is required for those nodes to join Open subgroups. - Startup sweep. The sweep is logged at
info!level per group re-driven and atwarn!on error. A clean node with no buffered ops completes the sweep in a single pass with no log output beyond the start/end lines. - Born-Open subgroups. Creating a subgroup with
visibility: "open"is now atomic — no separateSubgroupVisibilitySetop is needed. Any tooling that issued a two-step create+flip can be simplified.
Namespace Genesis — RootOp::NamespaceCreated
(#2474 / #2932) A new NamespaceCreated { founder: PublicKey }
variant is appended to the RootOp enum. It is the first op in every
namespace DAG: no parents, nonce 1, signed with the namespace key. It carries the
namespace_created op-kind label and is classified as a cheap-timeout op in
governance_broadcast.
Wire break. NamespaceCreated is appended at the end of the
RootOp enum so existing Borsh discriminants do not renumber. It is nevertheless a
Borsh-breaking addition for any consumer that pins calimero-governance-types. All
nodes in a namespace must coordinate a reset before deploying the new binary. Consumers such as
mero-tee must bump their core-rev dependency accordingly.
Apply Rules
The handler in ops/namespace/namespace_created.rs is self-authorizing
— it does not call require_namespace_admin. Established-ness is keyed solely on
admin_identity != PLACEHOLDER_ADMIN_IDENTITY. The handler branches as follows:
- Not established + has parents: returns
Err(NamespaceCreatedRejected(NotGenesis { parent_count })). This is load-bearing: anErrpropagates beforeadvance_dag_head, so the head remains empty and a subsequent parentless genesis can still apply. - Not established + no parents + signer ≠ founder: returns
Err(NamespaceCreatedRejected(SignerNotFounder)). - Not established + no parents + signer == founder: writes root meta with
admin == owner == founder(preserving other fields from any existing partial meta), unconditionally upserts the founder'sAdminmember row, and seeds defaultCAN_JOIN_OPEN_SUBGROUPScapabilities if absent. - Established + same founder + no parents (genesis-shaped re-arrival):
ensures the
Adminmember row (upgrade-only, never downgrade), repairs a divergedowner_identityback to the founder, seeds default caps if absent. ReturnsOk. - Established + same founder + has parents: pure no-op
Ok(not genesis-shaped; no state mutation). - Established + different founder: pure no-op
Ok(anti-hijack guard).
Placeholder Admin Identity
A module-level constant PLACEHOLDER_ADMIN_IDENTITY: [u8; 32] = [0u8; 32] and
companion function placeholder_admin_identity() -> PublicKey are defined in
governance-store/src/lib.rs. The all-zeros encoding is cryptographically safe: it
corresponds to the Ed25519 torsion/identity point, outside the prime-order subgroup, so no real
keypair can produce it.
Seed Behavior Change — Authority No Longer Conferred by seed_bootstrap_admin_if_absent
seed_bootstrap_admin_if_absent now writes the PLACEHOLDER_ADMIN_IDENTITY
sentinel as both admin_identity and owner_identity. The
KeyDelivery signer receives a non-authoritative GroupMemberRole::Member
row instead of Admin. The real founding admin is established only when the
NamespaceCreated genesis op is applied:
- Genesis arrives after seed: the handler detects the placeholder and
overwrites
admin_identity/owner_identitywith the founder, then upgrades the founder's member row toAdmin. - Genesis arrives before seed: the namespace is already established; the seed writes into an already-established store and the anti-hijack no-op path applies on any re-arrival of the genesis op.
Both orderings converge on the genesis-supplied founder as the authoritative admin. The former
trust-on-first-KeyDelivery behavior — where the deliverer's identity was pinned as
admin — has been removed. Any operator documentation or threat-model notes describing
the seed as the source of admin authority are now obsolete.
Trust Residual on Bare Replicas (#2932)
A TEE replica that has received a KeyDelivery but not yet the
NamespaceCreated genesis op is in a partially-established state: it holds
the placeholder admin identity and a Member row for the deliverer. It cannot
authorize admin-level ops until the genesis op arrives and overwrites the placeholder. Operators
should ensure the genesis op is replicated promptly; the startup sweep described in the
Re-drive Buffered Ops section will retry any buffered ops once the meta row is
established.
New Error Types
NamespaceCreatedRejection::SignerNotFounder— signer key does not match the declaredfounderfield.NamespaceCreatedRejection::NotGenesis { parent_count }— the op has parents but the namespace is not yet established.ApplyError::NamespaceCreatedRejected(NamespaceCreatedRejection)— wraps the above and is returned from the apply handler without advancing the DAG head.
GroupKeyring::delete_key_by_id
A new method GroupKeyring::delete_key_by_id(key_id: &[u8; 32]) deletes a
single stored group key by its id. Unlike delete_all_for_group it does not require
the membership-removed purge precondition. Its sole intended use is the namespace-root genesis
rollback path, where a key installed speculatively must be removed if the genesis op is
subsequently rejected.
Key Files
calimero-governance-types/src/root_op.rs—RootOp::NamespaceCreatedvariant (appended).governance-store/src/ops/namespace/namespace_created.rs— apply handler.governance-store/src/lib.rs—PLACEHOLDER_ADMIN_IDENTITYconstant andplaceholder_admin_identity()helper.governance-store/src/errors.rs—NamespaceCreatedRejectionenum andApplyError::NamespaceCreatedRejectedvariant.governance-store/src/keyring.rs—GroupKeyring::delete_key_by_id.governance-store/src/namespace/tests.rs,governance-store/src/tests.rs— regression and unit tests (13 new cases including the#2474regression guard).
State-Hash Staleness Checks Removed
As of this change, the state_hash field has been removed from the governance op
wire format entirely. The warn-and-apply staleness check that previously existed in
apply_local_signed_group_op_at_cut and apply_group_op_inner has been
deleted along with all supporting infrastructure. The per-signer nonce window is now the
sole anti-replay and deduplication gate on the apply path.
What Was Removed
- Staleness check blocks in
apply_local_signed_group_op_at_cutandNamespaceGovernance::apply_group_op_inner— the code that recomputed the group or namespace state hash and emitted atracing::warn!on mismatch is gone. No warning of the formlocal group op state_hash mismatchwill ever appear; any monitoring or alerting on that string can be retired. NamespaceGovernance::state_hash_for_op()— computed the appropriate hash domain for a namespace op at sign time.root_op_commits_to_namespace_state()— a module-level whitelist ofRootOpvariants whose correctness previously depended on namespace state.pre_apply_state_hashcapture inGroupGovernancePublisherand thestate_hashparameter threading throughpublish_post_gate,sign_and_publish_post_gate, andsign_and_publish_without_apply.GroupHandle::compute_state_hash()— a public thin wrapper overMetaRepository::compute_state_hashthat had no remaining callers.
Wire and Serialization Impact
The synthesized SignedGroupOp inside decrypt_and_apply_group_op no
longer copies ns_op.state_hash (the field is gone from the struct). The DAG delta
helpers signed_op_to_delta and signed_namespace_op_to_delta now pass
[0u8; 32] as expected_root_hash because governance ops no longer carry
a meaningful root claim.
Deleted Tests
The following unit tests and their shared setup_state_hash_test_fixture helper were
deleted because they pinned behaviour that no longer exists:
namespace_group_op_zero_state_hash_bypasses_checknamespace_group_op_with_current_state_hash_appliesnamespace_group_op_with_stale_state_hash_applies_with_warninggroup_op_with_stale_hash_and_absent_meta_appliesnamespace_root_op_with_current_state_hash_appliesnamespace_root_op_with_stale_state_hash_applies_with_warningstate_hash_allows_sequential_opsstate_hash_zero_skips_validation
Operator Notes
- The absent-meta bypass documented in the Re-drive Buffered Ops
section (skip the staleness check when the subgroup
GroupMetarow does not yet exist) is also gone — there is no check left to bypass. The absent-meta case is now unremarkable. - Any tooling or client library that populated
state_hashwhen constructing aSignedGroupOporSignedNamespaceOpmust be updated to omit the field. Passing a non-zero value is a wire error on the new schema. - The nonce-window dedup path is unchanged. A replayed op is still detected and returns
Ok(())early, as before.