CRDT State
Ahdapa replicates cluster state using Conflict-free Replicated Data Types (CRDTs). All replicated state lives in IdpCrdt (src/crdt/mod.rs). The design goal is that any node can merge state from any other node in any order and the result is the same — no coordination, no leader, no quorum.
The primitive implementations (OrMap, LwwMap, LwwRegister in src/crdt/primitives.rs, and the causal family in src/crdt/causal/) are thin wrappers around the agirru-crdt crate, extracted from ahdapa’s own original CRDT design into a standalone, generic library. The wrapper types keep the same names, method signatures, and call sites this crate always had — the migration was an internal swap of what backs them, not a rewrite of how they’re used.
IdpCrdt fields
#![allow(unused)]
fn main() {
pub struct IdpCrdt {
pub signing_keys: OrMap<String, SigningKeyEntry>,
pub active_kid: LwwRegister<String>,
pub wrapping_key_id: LwwRegister<String>,
pub cluster_nodes: OrMap<String, NodeEntry>,
pub clients: OrMap<String, ClientEntry>,
pub refresh_families: LwwMap<String, RefreshFamilyState>,
pub revoked_sessions: LwwMap<String, i64>,
pub scope_definitions: LwwMap<String, ScopeDefinition>,
pub hbac_rules: RuleSet,
pub hbac_gen: u64, // local-only, not serialised
pub hbac_clock: HbacClock, // local-only, not serialised
pub ipa_idp_overrides: LwwMap<String, IpaIdpOverride>,
// ... several more LWW-Map fields for SPIFFE, upstream tokens, SAML2, federation policies
pub rar_types: LwwMap<String, RarTypeEntry>,
pub rar_rules: RarRuleSet,
pub rar_gen: u64, // local-only, not serialised
}
}
| Field | CRDT type | Semantics |
|---|---|---|
signing_keys | OR-Map | Signing key entries indexed by kid. Each entry carries public_key_der, algorithm (e.g. "ES256", "EdDSA", "ML-DSA-44"), and not_after. The private_key_der field is #[serde(skip_serializing, default)] — it is stored node-locally in node_keys and is never gossiped. Keys may be revoked (tombstoned) via DELETE /api/admin/keys/{kid}; the JWKS endpoint serves only live (non-tombstoned) keys via live_values(). Tombstones are GC-purged after tombstone_ttl_secs by the periodic maintenance task. |
active_kid | LWW-Register | The kid most recently set as active by the local node’s key rotation. Not used for signing lookups — each node signs with its own local key regardless of this value. |
wrapping_key_id | LWW-Register | UUID string identifying the cluster AEAD wrapping key. The actual 32-byte key is stored node-locally in node_keys.wrapping_key_cms_der (CMS-sealed to the node’s own KEM key) and is never gossiped. Latest timestamp wins. |
cluster_nodes | OR-Map | Registered cluster nodes (node_id → certificate + public key). Soft deletes via tombstones. |
clients | OR-Map | OAuth2 client registrations. Soft deletes via tombstones. |
refresh_families | LWW-Map | Per-family max_index for refresh token rotation chain detection. |
revoked_sessions | LWW-Map | Per-subject session revocation timestamps (sub → revoked_at unix seconds). Populated on logout and back-channel logout when distributed_mode >= eventual. Any cluster node rejects session cookies whose iat is older than the stored revoked_at for that subject. Latest timestamp wins (concurrent revocations for the same subject converge to the most recent one). Entries older than session_ttl are purged periodically via purge_old_revocations. |
scope_definitions | LWW-Map | Scope-to-claim mappings (scope_name → ScopeDefinition). Each ScopeDefinition carries name, description, claims: Vec<String>, is_system: bool, always_disclosed_claims: Vec<String>, and claim_attributes: HashMap<String, String> — a per-scope map binding each claim to an explicit directory attribute (e.g. phone_number → telephonenumber); a claim absent from the map resolves by identity. Eight scopes are seeded on first startup; only openid and offline_access carry is_system = true and cannot be modified or deleted (the other six, including directory.read, are editable). The seeded phone and address scopes ship claim→attribute bindings (phone_number/phone_number_verified → telephonenumber, postalCode → postaladdress). Custom scopes are created and deleted via the admin API; deletion sets is_tombstone = true in the LWW entry. The UserInfo endpoint resolves claim names against FullUserEntry first-class fields, then falls through to raw_attrs LDAP attributes for any unrecognised name. The discovery scopes_supported and claims_supported fields are rebuilt from this map on every request. |
hbac_rules | RuleSet (src/crdt/hbac/) | Identity HBAC policy rules, now a native IdpCrdt field merged/deltable exactly like every other field — there is no separate op-log or mirrored copy. Rule existence is an RW-Set of RuleIds; rule content is stored per-RuleId as an HBACRule whose axes (users, clients, scopes, networks, device groups, MFA bypass, required ACR) each use a security-conservative causal CRDT primitive (RW-Set or DW-Register, remove-wins/disable-wins). RuleBuilder (.rule(&mut clock, name, description)...) mutates hbac_rules directly and synchronously — there is no op-emission step to keep in sync. See the hbac_clock row below for how causal tags are minted. |
rar_types | LWW-Map | RFC 9396 authorization_details types (type_id → RarTypeEntry { description, schema, comparator, source }). Seeded from [authorization_details] types when never defined (a deleted static type stays deleted). |
rar_rules | RarRuleSet (src/crdt/rar.rs) | RFC 9396 rules keyed by name: existence is an RW-Set (remove-wins), enabled a DW-Register (disable-wins), the description and the body (type_id, match_mode, conditions) causal LWW registers — the body as one register so concurrent edits never mix. Tags come from hbac_clock, and the set is persisted in the crdt_hbac_rules blob, so one vector clock covers both rule sets for compaction. Rules are default-deny (a detail needs an enabled rule of its type), which keeps remove-wins/disable-wins conservative. |
rar_gen | u64, local-only, #[serde(skip)] | Same role as hbac_gen, for rar_rules. |
hbac_gen | u64, local-only, #[serde(skip)] | The value of CRDT_GENERATION when hbac_rules was last modified. Lets delta_since/delta_range skip re-sending the (potentially large) HBAC blob to peers that already have it, without needing per-rule generation tracking. |
hbac_clock | HbacClock, local-only, #[serde(skip)] | Mints fresh causal tags (OpTag { replica, physical_ts, logical_ts, vclock }) for local hbac_rules mutations. Owns a replica_id (derived deterministically from this node’s node_id via replica_id_from_str, so it’s stable across restarts), a local Lamport counter, and a VectorClock. Unlike the rest of IdpCrdt, this is persisted — see Persistence — because agirru_crdt’s primitives don’t expose raw tag lists for scan-based clock recovery on restart. |
ipa_idp_overrides | LWW-Map | Per-IPA-IdP ACR/AMR overrides (ipa-<slug> → IpaIdpOverride). Each IpaIdpOverride carries default_acr: Option<String> and default_amr: Vec<String>. Stores only the two writable fields — all LDAP-sourced attributes (issuer URI, client ID, scopes, callback path) remain read-only and are never stored in the CRDT. Set via PUT /api/admin/federation/ipa-idps/{id}; applied at find_upstream() time by patching the in-memory UpstreamIdpConfig cloned from AppState.ipa_upstream_idps. Persisted in crdt_ipa_idp_overrides and gossiped to all nodes so overrides survive restarts and reach every cluster member. |
IdpCrdt implements agirru_crdt::{Merge, Delta, ReplicaScoped} directly (src/crdt/mod.rs) — merge/delta_since/delta_range aggregate one call per field, with hbac_rules and rar_rules special-cased to bump hbac_gen / rar_gen only when their merge actually changed something. ReplicaScoped::retain_replicas is scoped only to hbac_rules and rar_rules — it’s the mechanism AhdapaGossipHooks uses to reject a gossip peer trying to introduce or echo back HBAC tags for a replica it doesn’t own (see Admission filters).
CRDT_GENERATION counter
CRDT_GENERATION (src/crdt/mod.rs) is a process-wide agirru_crdt::GenerationClock —
an Arc<AtomicU64> wrapper from the agirru-crdt crate, LazyLock-initialised. It is
incremented on every mutation that actually changes CRDT state: new entries inserted via
insert or merge, tombstones applied, LWW values set when the incoming timestamp wins.
When a merge produces no net change (a redundant delivery of data already known), the
counter does not advance and the field’s merge returns false — every primitive’s
merge now reports whether it actually changed anything, which IdpCrdt::merge
aggregates with |= across all fields into a single “did this round change local state”
bool.
Per-peer generation bookkeeping — the equivalent of what used to be ahdapa’s own
peer_last_gen/peer_response_gen maps — is now owned internally by
agirru_engine::GossipEngine’s PeerBook, not by ahdapa. Ahdapa doesn’t see or manage
this state directly; it only implements the Storage/Delta primitives the engine calls
to build deltas and detect “nothing changed since last sync.” See
Gossip Protocol for how the engine uses this to decide what to send.
CRDT primitives
All three wall-clock primitives below are backed by the corresponding agirru_crdt type
(LwwRegister → agirru_crdt::LwwRegister, OrMap/LwwMap → agirru_crdt::RwMap).
Every merge now returns bool — true if the merge actually changed local state,
false for a redundant/no-op delivery — which IdpCrdt::merge aggregates across all
fields to decide whether to bump CRDT_GENERATION and whether agirru_engine should
fire its on_merged hook and changed notification at all.
LwwRegister
Last-Write-Wins Register. The value with the higher timestamp wins. On equal timestamps, the node with the lexicographically greater node_id wins (deterministic tie-breaking).
#![allow(unused)]
fn main() {
impl<T> LwwRegister<T> {
pub fn set(&mut self, value: T, timestamp: i64, node_id: &str);
pub fn get(&self) -> Option<&T>;
pub fn merge(&mut self, other: Self) -> bool;
}
}
Used for active_kid and wrapping_key_id. Setting a value with an older timestamp is a no-op, making set idempotent.
OrMap
Observed-Remove Map. Supports soft deletes via tombstones. Merge semantics: the union of live entries, where any tombstone suppresses its entry on both sides.
#![allow(unused)]
fn main() {
impl<K, V> OrMap<K, V> {
pub fn insert(&mut self, key: K, value: V, timestamp: i64);
pub fn remove(&mut self, key: &K, timestamp: i64); // sets tombstone
pub fn upsert(&mut self, key: K, value: V, timestamp: i64); // for updates
pub fn get(&self, key: &K) -> Option<&V>; // None for tombstoned
pub fn live_values(&self) -> impl Iterator<Item = (&K, &V)>;
pub fn retain_keys<F: Fn(&K) -> bool>(&mut self, allowed: F); // admission filtering
pub fn merge(&mut self, other: Self) -> bool;
pub fn purge_old_tombstones(&mut self, cutoff: i64); // drops tombstones older than cutoff
}
}
Used for cluster_nodes and clients. A tombstone wins over a live entry on merge —
deleting a client on any node will eventually suppress it everywhere.
remove records a tombstone even when the key is absent from the local map. This is
necessary to prevent entry resurrection on out-of-order gossip delivery: if a remove
arrives at a node before the corresponding insert (because gossip rounds fire in
different orders), the pre-emptive tombstone suppresses the subsequent insert when it
eventually arrives.
retain_keys(allowed) drops every entry whose key fails the predicate — this is what
AhdapaGossipHooks::filter_inbound calls on cluster_nodes to enforce the node
allowlist (see Admission filters).
purge_old_tombstones(cutoff) permanently removes tombstoned entries whose
tombstone_at timestamp is older than cutoff. Called by the periodic maintenance task
(src/routes/gossip/maintenance.rs) with cutoff = now - tombstone_ttl_secs. Entries
that are still live (not tombstoned) are never removed by this call.
LwwMap
A map where each key has an independent LWW-Register value.
#![allow(unused)]
fn main() {
impl<K, V> LwwMap<K, V> {
pub fn set(&mut self, key: K, value: V, timestamp: i64, node_id: &str);
pub fn get(&self, key: &K) -> Option<&V>;
pub fn merge(&mut self, other: Self) -> bool;
pub fn retain<F: FnMut(&V) -> bool>(&mut self, f: F); // remove entries where f returns false
}
}
Used for refresh_families. Each family_id key has its own LWW value (RefreshFamilyState
containing max_index and expires_at). The highest max_index seen propagates on
merge; setting max_index = u64::MAX is the revocation signal.
retain(f) removes entries where f(value) returns false. Used for expired-family
purge: IdpCrdt::purge_expired_families(now) calls retain(|s| s.expires_at > now).
This runs on the periodic maintenance task’s cadence (src/routes/gossip/maintenance.rs,
independent of gossip rounds), not per gossip round — an expired-but-not-yet-purged
family riding along in one extra gossip round is a bandwidth/size concern only (expired
families are already rejected on use elsewhere), not a correctness one.
Persistence
IdpCrdt is persisted to the local database on every mutation and after every gossip merge that actually changed state. The schema mirrors the CRDT structure exactly:
| Table | CRDT field |
|---|---|
crdt_signing_keys | signing_keys (OR-Map rows; tombstone + tombstone_at columns added in migration 0017_signing_key_tombstone.sql; saml_cert_der column added in migration 0041_saml2_signing_cert.sql) |
crdt_active_kid | active_kid (single row keyed by id=1; INSERT OR REPLACE) |
crdt_wrapping_key | wrapping_key_id (single row keyed by id=1; stores UUID only; INSERT OR REPLACE) |
crdt_cluster_nodes | cluster_nodes (OR-Map rows with tombstone columns) |
crdt_clients | clients (OR-Map rows with tombstone columns) |
crdt_refresh_families | refresh_families (LWW-Map rows) |
crdt_revoked_sessions | revoked_sessions (LWW-Map rows: local_sub, revoked_at, set_by_node) |
crdt_scopes | scope_definitions (LWW-Map rows: name, description, claims JSON, is_system, always_disclosed_claims JSON, claim_attributes JSON, set_at, set_by_node, is_deleted, deleted_at; claim_attributes added in migration 0042_claim_attributes.sql) |
crdt_hbac_rules | hbac_rules + hbac_clock + rar_rules (single JSON blob row — a HbacSnapshot { rules: RuleSet, clock: HbacClock, rar_rules: RarRuleSet }, loaded/persisted as a unit via load_hbac_rules/persist_hbac_rules, separately from the rest of load_from_db/persist_to_db). Build it with IdpCrdt::causal_snapshot() so neither rule set is dropped; rar_rules is absent (defaulted) in blobs written before RAR support. |
crdt_rar_types | rar_types (LWW-Map rows: type_id, description, type_schema JSON, comparator JSON, source, set_at, set_by_node, is_deleted, deleted_at; migration 0044_crdt_rar_types.sql) |
crdt_ipa_idp_overrides | ipa_idp_overrides (LWW-Map rows: id, default_acr, default_amr JSON, set_at, set_by_node, is_deleted, deleted_at; migration 0021_crdt_ipa_idp_overrides.sql) |
Three additional nullable columns were added to crdt_clients in migration 0022_client_kerberos.sql to support the kerberos_client_auth token endpoint authentication method:
| Column | Type | Purpose |
|---|---|---|
kerberos_principal | TEXT (nullable) | Exact Kerberos service principal for single-machine clients (e.g. host/node1.example.com@REALM). |
kerberos_principal_pattern | TEXT (nullable) | Glob pattern for template clients (e.g. host/*@REALM). * matches any characters except @. |
kerberos_hbac_service | TEXT (nullable) | FreeIPA HBAC service name that gates access via the replicated HBAC rule set. |
Exactly one of kerberos_principal or kerberos_principal_pattern is set per Kerberos client; all three columns are NULL for non-Kerberos clients.
At startup, IdpCrdt::load_from_db reconstructs every field except hbac_rules/hbac_clock from the database; those two (and rar_rules) are loaded separately via load_hbac_rules(db, replica_id) (see Persistence above) and assigned onto the freshly-loaded IdpCrdt. Static HBAC and RAR rules and RAR types are then seeded from config, and the snapshot and types are persisted right away, so a restart neither re-seeds nor re-mints tags from a reset clock. replica_id is derived deterministically from this node’s node_id (replica_id_from_str), so a fresh HbacClock seeded on first startup is stable across restarts without needing extra stored state. Revoked session entries older than revocation_cutoff (derived from session_ttl) are filtered at load time so a restarted node does not carry stale revocations. Built-in scope definitions are seeded into crdt_scopes on first startup if not already present.
Causal CRDT primitives (hbac_rules)
hbac_rules uses a separate primitive family from the rest of IdpCrdt — true causal
(vector-clock) conflict resolution rather than wall-clock (timestamp, node_id) LWW.
This is a documented security invariant: all CRDT merge resolutions for HBAC access
control must be conservative (remove-wins, disable-wins), so that a concurrent stale
re-add or re-enable can never widen access. Wall-clock LWW cannot guarantee this under
clock skew — a concurrent “add” could numerically outrace a “remove” and win.
These live in src/crdt/causal/ — thin wrappers, again, around agirru_crdt’s
RwMap/DwRegister/LwwRegister/VectorClock/CausalTag types:
| Type | Backing agirru_crdt type | Used for |
|---|---|---|
RWSet<T> | RwMap<T, ()> | Membership sets (users, clients, scopes, networks, device groups) — remove-wins. |
DWRegister | DwRegister | Boolean category flags and mfa_bypass — disable-wins. |
causal LWWRegister<T> | LwwRegister<T> | Single-value axes (e.g. rule name/description) where plain LWW is safe — this is a distinct type from src/crdt/primitives::LwwRegister above; do not confuse the two, they compare tags differently. |
OpTag | CausalTag | { replica: ReplicaId, physical_ts: i64, logical_ts: u64, vclock: VectorClock } — minted by HbacClock::next_tag(), never constructed directly by callers. |
HbacClock (src/crdt/hbac/clock.rs) owns the local replica’s tag-minting state:
a replica_id, a local Lamport counter, and a VectorClock. It replaces what used to be
a separate hbac-crdt::OpLog’s clock-ownership role — now that the materialised rule
state lives directly on IdpCrdt.hbac_rules, HbacClock only ever mints tags, it never
owns rule content. Because agirru_crdt’s primitives deliberately don’t expose raw tag
lists (by design), the clock can’t be reconstructed by scanning existing tags on
restart the way the old OpLog::restore_clock_from_state did — so it’s persisted
directly alongside hbac_rules instead (see Persistence).
Causal LWWRegisters order writes by (physical_ts, logical_ts, replica) only, and
HbacClock mints physical_ts = 0. So that a write made after observing a remote one
sorts after it, HbacClock::observe(vclock, max_logical_ts) both merges the vector
clock and lifts the local counter past the highest logical timestamp in the merged LWW
registers (RuleSet/RarRuleSet::max_lww_logical_ts) — the Lamport rule, as
agirru_crdt::Clock::observe does for its own tags.
HBACRule/RuleSet (src/crdt/hbac/rule.rs) implement the local Merge trait
(fn merge(&mut self, other: &Self) -> bool), bridging to agirru_crdt::Merge
internally with a throwaway GenerationClock per call — safe because nothing in this
crate consumes Delta/generation numbers for these fields. ReplicaScoped is
implemented by delegating to every field (HBACRule) or to existence plus every rule
(RuleSet), which is what lets IdpCrdt::retain_replicas — scoped only to hbac_rules and rar_rules
— filter by replica ownership during gossip admission.
Bootstrap
On a brand-new node with an empty database:
load_from_dbreturns an all-default (empty)IdpCrdt.bootstrap_node_kem_key()generates an ML-KEM-768 key pair and an ECDSA P-256 gossip signing key pair; stores all four DER values innode_keys.bootstrap_signing_key()generates a JWT signing key pair using the algorithm from[server] jwt_signing_algorithm(default: ES256), stores the private key innode_keys.jwt_signing_priv_der(never in CRDT), computeskid = base64url(SHA256(spki_der)[..8]), inserts aSigningKeyEntry(public key + algorithm only) intosigning_keys, and setsactive_kid. If an existing key innode_keysuses a different algorithm than the configured one, a new key is generated automatically (algorithm upgrade path).bootstrap_wrapping_key()checksnode_keys.wrapping_key_cms_der:- If present: decrypts with
open_raw()to recover the 32-byte key; restoreswrapping_key_idto the CRDT if not already set. - If absent: generates 32 random bytes, seals them with
seal_raw()to the node’s own KEM public key, stores the CMS blob innode_keys, generates a UUID, and publishes the UUID to the CRDT aswrapping_key_idwith timestamp=1.
- If present: decrypts with
persist_to_dbflushes to the database.
When gossip is enabled, the node receives the cluster’s existing CRDT state on the first gossip round and merges it. If the peer’s wrapping_key_id differs from the local UUID, the node pulls the actual key via GET /api/gossip/wrapping-key.
Key rotation
Rotating the signing key is an admin operation (POST /api/admin/keys/rotate):
- Generate a new key pair using the algorithm from
[server] jwt_signing_algorithm(default: ES256). - Compute
kid = base64url(SHA256(spki_der)[..8]). - Store the private key in
node_keys.jwt_signing_priv_der(replaces previous active key). The private key is never written tocrdt_signing_keys. - Insert a
SigningKeyEntrywith public key + algorithm intocrdt_signing_keys(OR-Mapinsert) and updatecrdt_active_kid(LWW INSERT OR REPLACE). A SAML2 signing certificate is generated with each new key (365-day validity) and stored inSigningKeyEntry.saml_cert_der(serde rename"sc", default; the manualPartialEqincludes it). The certificate is served in the IdP metadata and in outgoing SAMLKeyInfo, pinned perkidso it is stable across nodes and refreshes. - Write to the in-memory CRDT.
- Gossip propagates the new public key entry to all peers immediately: the CRDT write calls
engine.notify_local_change(), triggering an immediate off-cycle gossip round instead of waiting for the next periodic tick (see Gossip Protocol). Convergence is typically under 12 ms in a full-mesh cluster.
Old keys remain in signing_keys and continue to validate tokens signed before the rotation until their not_after timestamp passes. The JWKS endpoint (/jwks) serves all live (non-tombstoned) keys. Any node that issued a token with a given kid holds the corresponding private key; other nodes can still validate those tokens using the gossiped public key.
A signing key can be explicitly revoked before its not_after deadline via DELETE /api/admin/keys/{kid}. This tombstones the OR-Map entry so that live_values() skips it, and the key is no longer served from /jwks. If the revoked key is the active kid, a warning is logged; a key rotation (POST /api/admin/keys/rotate) should follow immediately. Tombstones for revoked signing keys are GC-purged by the same periodic maintenance task that processes client and node tombstones.
Refresh token family lifecycle
RefreshFamilyState carries an expires_at unix timestamp (set when the family is
created from the max_refresh_token_age configuration). Two purge paths keep the CRDT
bounded:
-
In-memory purge (periodic maintenance task):
IdpCrdt::purge_expired_families(now)callsrefresh_families.retain(|s| s.expires_at > now). Runs on the maintenance task’s cadence (src/routes/gossip/maintenance.rs), independent of gossip rounds — see the note under CRDT primitives → LwwMap above. -
Database purge (approximately hourly):
cleanup_expired_familiesdeletes rows fromcrdt_refresh_families WHERE expires_at < now. On startup,load_refresh_familiesalso filters out already-expired rows, so a crashed or restarted node does not re-inflate its CRDT from stale DB rows.
Refresh token revocation
Revoking a refresh token family (DELETE /api/admin/refresh-families/{family_id}) sets max_index = u64::MAX in the CRDT. Any node that receives this value via gossip will reject all future refresh tokens in that family, because every valid token_index is less than u64::MAX.
Partition behaviour
During a network partition, each node operates on its local CRDT snapshot. After the partition heals, the first gossip exchange merges the diverged states. For each CRDT type:
- OR-Map (signing keys): union of live entries; tombstones propagate — a key revoked on one side of the partition will suppress the corresponding entry on reconnect.
- LWW-Register (active_kid, wrapping_key_id): the highest timestamp wins; the losing side’s write is silently dropped. For
wrapping_key_id, the winning UUID triggers an on-demand pull of the actual key from the peer that set it. - OR-Map (clients, cluster_nodes): union of live entries; tombstones propagate on merge.
- LWW-Map (refresh_families): per-key, highest timestamp wins — a
max_indexset tou64::MAX(revocation) on one side of the partition propagates and invalidates any lower indexes issued during the partition. - LWW-Map (revoked_sessions): per-subject, highest timestamp wins — a revocation recorded on one side of the partition propagates and invalidates sessions whose
iatis before the winningrevoked_at. Only present whendistributed_mode >= eventual; inoffmode the field is populated but stays empty and is only checked node-locally. - LWW-Map (scope_definitions): per-scope-name, highest timestamp wins — a scope created or deleted on one side of the partition propagates on merge. Deletions (tombstones) set
is_deleted = truein the LWW value; the winning entry suppresses the scope from discovery and UserInfo claim resolution on all nodes. RuleSet(hbac_rules): each axis within a rule uses a security-conservative causal CRDT — RW-Set (remove-wins) for member sets and DW-Register (disable-wins) for category flags andmfa_bypass, both backed byagirru_crdt(see Causal CRDT primitives above). Rule existence is an RW-Set ofRuleIds; deleting a rule on one side of the partition propagates and suppresses it on the other side after merge. A concurrent stale re-enable or re-add on the other side of the partition cannot widen access because disable-wins and remove-wins semantics apply.