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.

Opt-in
per-member flag
DAG
event-driven
20/min
default rate limit
Replay
offline catch-up

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-join joins 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:

  1. Generates a TDX attestation quote bound to the node's namespace identity pubkey.
  2. Broadcasts TeeAttestationAnnounce on the namespace topic.
  3. Polls for admission (up to 30 s) by calling list_group_contexts. Once the verifier's MemberJoinedViaTeeAttestation op has propagated, the list succeeds.
  4. Joins all existing contexts in the group.
  5. 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_admit subscriber (PR #2772) in which an existing key-holder admits the node in response to SubgroupCreated / TeeMemberAdmitted events. 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 returns ReadOnlyTee for an inherited member carrying that role.

Operator Notes

  • Observability. Every auto-join emits a structured info! log line with group_id and context_id. Failures emit warn! with the underlying error. No new metrics — the log stream is enough for postmortems.
  • state_hash mismatch warnings. In apply_local_signed_group_op_at_cut, a mismatch between the op's signed state_hash and the recomputed current hash previously caused a hard error that dropped the op. It now emits a structured tracing::warn! (including op variant, expected/actual hashes, nonce, and signer) and proceeds to apply the op. A local group op state_hash mismatch warning 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 the state_hash comparison block, so a replayed op returns Ok(()) 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::apply gate now evaluates membership-path authorization against the causal-cut projection state via NamespaceApplyCtx::projection_membership_path, which returns Option<AtCutMembershipPath>. 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 former shadow_membership_path helper — which accepted an eagerly-computed live path, compared it against the projection, and emitted unified_projection_divergence / plane=membership-path warnings 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>>> <<Operator Notes
    • Observability. Every auto-join emits a structured info! log line with group_id and context_id. Failures emit warn! with the underlying error. No new metrics — the log stream is enough for postmortems.
    • Membership-path authorization. The 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.
    • FIND>>> <<Membership-path authorization. The 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.
    • Authorization source. PermissionChecker::is_admin and is_authorized_with_capability now use the projection-at-cut verdict as authoritative when available. Live store queries are a fallback only when the authorizer returns None. The last-admin removal/demotion gates (ensure_not_last_admin_removal and ensure_not_last_admin_demotion) likewise resolve against the projection at the op's parent cut via would_orphan_admins, falling back to live membership only when no apply-auth context is present. The shadow_last_admin divergence-logging helper and all associated unified_projection_divergence / plane=last-admin warnings have been removed now that the projection verdict is authoritative.
    • Shutdown. Call auto_follow::shutdown() to abort the handler task and its refill loop. Subsequent spawn calls will start a fresh handler.
    • Migration. GroupMemberValue was extended with auto_follow via 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 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.
    • FIND>>> <<Implementing 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 + OpEvent enum.
  • crates/context/src/auto_follow.rs — handler task, rate limiter, spawn/shutdown.
  • crates/context/src/group_store/mod.rsapply_group_op_mutations (now accepts parents and authorizer) handles MemberSetAutoFollow; apply_local_signed_group_op_at_cut is the new authorizer-aware public entry point; apply_local_signed_group_op is now a shim forwarding to it with the inert LIVE_FALLBACK_AUTHORIZER.
  • crates/context/src/group_store/membership.rsset_member_auto_follow helper.
  • crates/context/primitives/src/local_governance/mod.rs — the MemberSetAutoFollow op variant.
  • crates/store/src/key/group/mod.rsAutoFollowFlags and the backward-compatible BorshDeserialize for GroupMemberValue.
  • 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 publishes MemberSetAutoFollow signed by self.
  • crates/context/src/group_store/permission.rs (or equivalent) — PermissionChecker gains with_apply_auth; is_admin and is_authorized_with_capability use the projection-at-cut verdict as the authoritative decision when AtCutAuthorizer returns Some, falling back to live store queries otherwise. The shadow_admin helper and unified_projection_divergence logging have been removed. MembershipPolicy now optionally carries the op's parent cut hashes and an AtCutAuthorizer reference, set via the new with_apply_auth builder method. ensure_not_last_admin_removal and ensure_not_last_admin_demotion now delegate to the new would_orphan_admins helper, which calls AtCutAuthorizer::is_last_admin_at_cut to 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. The shadow_last_admin helper and all unified_projection_divergence / plane=last-admin warnings have been deleted. GroupApplyCtx::new calls .with_apply_auth(parents, authorizer) on the MembershipPolicy it constructs, activating the projection-at-cut last-admin gate for all group-op apply paths.
  • 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.
    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 from governance-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:

  1. Iterates every namespace the node holds an identity for.
  2. Finds groups with buffered ops whose key is already held (the inverse of groups_awaiting_key).
  3. Re-drives each one via redrive_encrypted_ops_for_group_counted.
  4. 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_admit skips it and no transient direct ReadOnlyTee row 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:

  • CreateGroupRequest gains a restricted field (default true).
  • The create-group-in-namespace REST endpoint accepts an optional visibility field ("open" or "restricted", default "restricted").

Operator Checklist

  • Wire break. The RootOp::GroupCreated Borsh 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 DefaultCapabilitiesSet from 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 at warn! 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 separate SubgroupVisibilitySet op 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: an Err propagates before advance_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's Admin member row, and seeds default CAN_JOIN_OPEN_SUBGROUPS capabilities if absent.
  • Established + same founder + no parents (genesis-shaped re-arrival): ensures the Admin member row (upgrade-only, never downgrade), repairs a diverged owner_identity back to the founder, seeds default caps if absent. Returns Ok.
  • 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_identity with the founder, then upgrades the founder's member row to Admin.
  • 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 declared founder field.
  • 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.rsRootOp::NamespaceCreated variant (appended).
  • governance-store/src/ops/namespace/namespace_created.rs — apply handler.
  • governance-store/src/lib.rsPLACEHOLDER_ADMIN_IDENTITY constant and placeholder_admin_identity() helper.
  • governance-store/src/errors.rsNamespaceCreatedRejection enum and ApplyError::NamespaceCreatedRejected variant.
  • governance-store/src/keyring.rsGroupKeyring::delete_key_by_id.
  • governance-store/src/namespace/tests.rs, governance-store/src/tests.rs — regression and unit tests (13 new cases including the #2474 regression 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_cut and NamespaceGovernance::apply_group_op_inner — the code that recomputed the group or namespace state hash and emitted a tracing::warn! on mismatch is gone. No warning of the form local group op state_hash mismatch will 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 of RootOp variants whose correctness previously depended on namespace state.
  • pre_apply_state_hash capture in GroupGovernancePublisher and the state_hash parameter threading through publish_post_gate, sign_and_publish_post_gate, and sign_and_publish_without_apply.
  • GroupHandle::compute_state_hash() — a public thin wrapper over MetaRepository::compute_state_hash that 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_check
  • namespace_group_op_with_current_state_hash_applies
  • namespace_group_op_with_stale_state_hash_applies_with_warning
  • group_op_with_stale_hash_and_absent_meta_applies
  • namespace_root_op_with_current_state_hash_applies
  • namespace_root_op_with_stale_state_hash_applies_with_warning
  • state_hash_allows_sequential_ops
  • state_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 GroupMeta row 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_hash when constructing a SignedGroupOp or SignedNamespaceOp must 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.