"""muse.core.hdkeys — Muse HD key derivation: six-level domain-first path. Muse derives all cryptographic keys from a single BIP39 mnemonic via SLIP-0010 Ed25519 hierarchical deterministic derivation. The path structure is designed to be a **semantic coordinate system** — every level answers exactly one question, and every key's purpose is readable from its path alone. Path structure -------------- :: m / purpose' / domain' / entity_type' / entity_id' / role' / index' │ │ │ │ │ │ │ │ │ │ │ └── Which rotation? 0'=current, 1'=pre-rotated, … │ │ │ │ └─────────── What does it do? 0'=sign, 1'=receive, 2'=provision, 3'=attest, 4'=delegate │ │ │ └───────────────────────── Which specific? 0', 1', 2', … │ │ └───────────────────────────────────────── What class? 0'=human, 1'=agent, 2'=org │ └──────────────────────────────────────────────────── What universe? 0'=identity, 1'=payments, 2'=code, 3'=music, 4'=midi, 5'=blockchain, … └──────────────────────────────────────────────────────────────────── What app? 1_075_233_755' (sha256(b"muse")[:4] & 0x7FFFFFFF) Purpose ------- ``1_075_233_755 = int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF`` Reproducible by anyone — not an arbitrary number. The high bit is masked to keep it in the valid unhardened range before the hardened offset is applied. Domains ------- Domains are **first-class entities** in the Muse key namespace. A key belongs to a domain before it belongs to an entity — the domain scopes the entire sub-tree beneath it. Domain indices are **hash-derived** — ``sha256(name)[:4] & 0x7FFFFFFF`` — using the same pattern as ``MUSE_PURPOSE``. This makes the namespace open: any third party can register a domain without a central committee. .. list-table:: :widths: 25 65 :header-rows: 1 * - Constant - Meaning * - :data:`DOMAIN_IDENTITY` (``domain_index("muse/identity")``) - Cross-domain auth. The key that answers "who are you?" on the Muse network. Used for MSign HTTP signing and MuseHub registration. One per human or agent — it is their passport, not their work credential. * - :data:`DOMAIN_PAYMENTS` (``domain_index("muse/payments")``) - MPay claims, financial settlement. * - :data:`DOMAIN_CODE` (``domain_index("muse/code")``) - Software VCS — commit provenance, code-review attestations. * - :data:`DOMAIN_MUSIC` (``domain_index("muse/music")``) - Stori audio production — project signing, master ownership. * - :data:`DOMAIN_MIDI` (``domain_index("muse/midi")``) - Maestro symbolic music — NL→MIDI content signing. * - :data:`DOMAIN_BLOCKCHAIN` (``domain_index("muse/blockchain")``) - On-chain operations (ERC-8004 identity, ERC-721, AVAX). secp256k1 keys for this domain use the ``b"Bitcoin seed"`` SLIP-0010 HMAC root — same path grammar, different curve. * - :data:`DOMAIN_GENERIC` (``domain_index("muse/generic")``) - Repos and entities with no registered domain plugin. First-class explicit value — never an empty string or None. Entity types ------------ .. list-table:: :widths: 10 25 65 :header-rows: 1 * - Value - Constant - Meaning * - 0 - :data:`ENTITY_HUMAN` - Human operator. Account 0 is always the primary identity. * - 1 - :data:`ENTITY_AGENT` - AI agent. Each agent slot receives a domain-scoped sub-seed from the operator — it cannot derive keys outside its granted domains. * - 2 - :data:`ENTITY_ORG` - Organisation or DAO. Governance and membership live above the key layer; the key tree records only that this principal is a collective. Roles ----- .. list-table:: :widths: 10 25 65 :header-rows: 1 * - Value - Constant - Meaning * - 0 - :data:`ROLE_SIGN` - Primary signing key for this domain (default). * - 1 - :data:`ROLE_RECEIVE` - Receiving / payment address key. * - 2 - :data:`ROLE_PROVISION` - Provisioning key — used during entity bootstrapping. * - 3 - :data:`ROLE_ATTEST` - Third-party attestation key (distinct from self-signing). * - 4 - :data:`ROLE_DELEGATE` - Scoped authority delegation (future). Domain-scoped agent delegation ------------------------------- An agent's sub-seed is derived from the parent's key tree at the domain level. This means an agent's capability is bounded by cryptography, not policy: :: # Operator grants a music agent only music-domain keys music_agent_seed = derive_agent_sub_seed(master_seed, domain=DOMAIN_MUSIC, agent_id=0) # A separate identity grant is needed for MuseHub auth auth_agent_seed = derive_agent_sub_seed(master_seed, domain=DOMAIN_IDENTITY, agent_id=0) # Agent uses each sub-seed independently — two separate key roots agent_identity_key = derive_identity_key(auth_agent_seed) agent_music_key = derive_domain_key(music_agent_seed, domain=DOMAIN_MUSIC) Compromising a music agent's seed cannot reveal the operator's identity key or any other domain's keys — SLIP-0010 hardened derivation guarantees this. Sub-seed composition -------------------- ``agent_sub_seed = dk.private_bytes + dk.chain_code`` (64 bytes) Both halves are required: the chain code enables further child derivation from the sub-seed root. The sub-seed is treated identically to a BIP39 master seed by all functions in this module. Two-tree architecture for blockchain ------------------------------------- Ed25519 keys (domains 0–5) and secp256k1 keys (domain 6) are derived from separate SLIP-0010 roots that share the same BIP39 mnemonic:: seed → HMAC("ed25519 seed", seed) → Ed25519 master (identity, payments, code, music, …) seed → HMAC("Bitcoin seed", seed) → secp256k1 master (blockchain / EVM / AVAX) Both trees use the same six-level path grammar. Blockchain-specific callers use the secp256k1 master key directly; this module handles only Ed25519. Examples -------- :: from muse.core.bip39 import generate_mnemonic, mnemonic_to_seed from muse.core.hdkeys import ( derive_identity_key, derive_domain_key, derive_agent_sub_seed, dk_to_ed25519, public_bytes_from_seed, DOMAIN_IDENTITY, DOMAIN_MUSIC, ENTITY_HUMAN, ENTITY_AGENT, ROLE_SIGN, ) mnemonic = generate_mnemonic() seed = mnemonic_to_seed(mnemonic) # Human operator's MuseHub identity (auth) key dk = derive_identity_key(seed) priv = dk_to_ed25519(dk) pub_bytes = priv.public_key().public_bytes_raw() # 32 bytes → register with MuseHub # Human operator's music signing key (Stori project provenance) music_dk = derive_domain_key(seed, domain=DOMAIN_MUSIC) # Spawn a music agent with a domain-scoped sub-seed agent_seed = derive_agent_sub_seed(seed, domain=DOMAIN_MUSIC, agent_id=0) agent_dk = derive_identity_key(agent_seed) # agent's own identity within music domain """ import hashlib from typing import TYPE_CHECKING from muse.core.slip010 import ( MUSE_PURPOSE, DerivedKey, SecretByteArray, Slip010Error, child_key, derive_path, hardened, master_key, to_ed25519_private_key, ) if TYPE_CHECKING: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey __all__ = [ # Errors "HdKeyError", # Domain index function "domain_index", # Domain constants "DOMAIN_IDENTITY", "DOMAIN_PAYMENTS", "DOMAIN_CODE", "DOMAIN_MUSIC", "DOMAIN_MIDI", "DOMAIN_BLOCKCHAIN", "DOMAIN_GENERIC", # Entity type constants "ENTITY_HUMAN", "ENTITY_AGENT", "ENTITY_ORG", # Role constants "ROLE_SIGN", "ROLE_RECEIVE", "ROLE_PROVISION", "ROLE_ATTEST", "ROLE_DELEGATE", # Agent slot mapping "agent_id_to_slot", # Path helper "muse_path", # Core derivation "derive_key", "derive_identity_key", "derive_domain_key", "derive_agent_sub_seed", # Key materialisation "dk_to_ed25519", "public_bytes_from_seed", ] # --------------------------------------------------------------------------- # Domain index function # --------------------------------------------------------------------------- def domain_index(name: str) -> int: """Return the canonical BIP32-compatible domain index for a named domain. Uses the first 4 bytes of ``sha256(name.encode("utf-8"))`` interpreted as a big-endian ``uint32`` masked to ``[0, 2^31 - 1]`` — the same pattern used to derive ``MUSE_PURPOSE`` from ``b"muse"``. This makes the domain namespace **open and decentralised**: any third-party domain can compute its own index without a central registry. The birthday bound for a 31-bit space is ~65k domains before a collision becomes likely — far beyond any realistic deployment. .. warning:: This mapping is **permanent**. Changing the algorithm or the canonical name string for an existing domain invalidates every key ever derived for that domain. Canonical name strings use the ``"muse/"`` convention for first-party domains. Parameters ---------- name: Canonical domain name string (e.g. ``"muse/identity"``, ``"muse/code"``). Returns ------- int Domain index in ``[0, 2^31 - 1]``. """ digest = hashlib.sha256(name.encode("utf-8")).digest() return int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF # --------------------------------------------------------------------------- # Domain constants # --------------------------------------------------------------------------- #: Cross-domain authentication. The "passport" key — used for MSign HTTP #: signing and MuseHub registration. One per human or agent. DOMAIN_IDENTITY: int = domain_index("muse/identity") #: MPay claims and financial settlement. DOMAIN_PAYMENTS: int = domain_index("muse/payments") #: Software VCS — commit provenance, code-review attestations. DOMAIN_CODE: int = domain_index("muse/code") #: Stori audio production — project signing, master ownership. DOMAIN_MUSIC: int = domain_index("muse/music") #: Maestro symbolic music — NL→MIDI content signing. DOMAIN_MIDI: int = domain_index("muse/midi") #: On-chain operations — ERC-8004, ERC-721, ERC-1155, AVAX. #: secp256k1 keys for this domain use a separate SLIP-0010 root #: (``b"Bitcoin seed"`` HMAC key) — same path grammar, different curve. DOMAIN_BLOCKCHAIN: int = domain_index("muse/blockchain") #: Repos and entities with no registered domain plugin. #: First-class explicit value — never an empty string or None. DOMAIN_GENERIC: int = domain_index("muse/generic") # --------------------------------------------------------------------------- # Entity type constants # --------------------------------------------------------------------------- #: Human operator. ENTITY_HUMAN: int = 0 #: AI agent. Each agent slot receives a domain-scoped sub-seed. ENTITY_AGENT: int = 1 #: Organisation or DAO. ENTITY_ORG: int = 2 # --------------------------------------------------------------------------- # Role constants # --------------------------------------------------------------------------- #: Primary signing key for a domain (default). ROLE_SIGN: int = 0 #: Receiving / payment address key. ROLE_RECEIVE: int = 1 #: Provisioning key — entity bootstrapping. ROLE_PROVISION: int = 2 #: Third-party attestation key (distinct from self-signing). ROLE_ATTEST: int = 3 #: Scoped authority delegation (reserved). ROLE_DELEGATE: int = 4 # --------------------------------------------------------------------------- # Errors # --------------------------------------------------------------------------- class HdKeyError(ValueError): """Raised when an HD key derivation request is invalid. Common causes: - Negative domain, entity_id, or index value. - Entity type or role outside the defined range. - Agent sub-seed requested for the human entity (account 0 is the human operator; sub-seeds are for agents only, ``entity_id >= 0`` under ``ENTITY_AGENT``). - Seed shorter than 16 bytes (propagated from :class:`~muse.core.slip010.Slip010Error`). Subclasses :class:`ValueError` for broad compatibility. Use ``except HdKeyError`` for precise handling. Examples -------- :: try: derive_key(seed, domain=-1) except HdKeyError as exc: print(f"key error: {exc}") """ # --------------------------------------------------------------------------- # Agent slot mapping # --------------------------------------------------------------------------- def agent_id_to_slot(agent_id: str) -> int: """Map an agent handle string to a stable BIP32-compatible slot index. Uses the first 4 bytes of ``sha256(agent_id.encode())`` interpreted as a big-endian ``uint32`` masked to ``[0, 2^31 - 1]`` (the valid range before the hardened offset is applied by SLIP-0010 callers). Properties ---------- - **Deterministic**: same handle always maps to the same slot. - **Collision-resistant**: birthday bound at ~65k handles for a 2^32 pre-image space — sufficient for any realistic swarm size. - **Platform-independent**: SHA-256 output is identical on all platforms. .. warning:: This mapping is **permanent**. Changing the algorithm invalidates every agent key ever derived. Never alter it after keys are in production. Parameters ---------- agent_id: Agent handle string (e.g. ``"claude-worker-01"``). The empty string is accepted (maps to a deterministic slot) but not recommended. Returns ------- int Slot index in ``[0, 2^31 - 1]``. Pass directly to :func:`derive_agent_sub_seed` as the ``agent_id`` parameter. """ digest = hashlib.sha256(agent_id.encode()).digest() return int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF # --------------------------------------------------------------------------- # Path helper # --------------------------------------------------------------------------- def muse_path( domain: int, entity_type: int = ENTITY_HUMAN, entity_id: int = 0, role: int = ROLE_SIGN, index: int = 0, ) -> str: """Return the canonical Muse derivation path string for the given coordinates. The returned string is suitable for passing directly to :func:`~muse.core.slip010.derive_path`. Parameters ---------- domain: Domain index (one of the ``DOMAIN_*`` constants). Must be >= 0. entity_type: Entity class (one of the ``ENTITY_*`` constants). Must be 0–3. entity_id: Entity slot within its type (0 = first, 1 = second, …). Must be >= 0. role: Key role within the domain (one of the ``ROLE_*`` constants). Must be 0–4. index: Key rotation index (0 = current, 1 = pre-rotated, …). Must be >= 0. Returns ------- str Path such as ``"m/1075233755'/0'/0'/0'/0'/0'"``. Raises ------ HdKeyError If any argument is out of range. Examples -------- :: assert muse_path(DOMAIN_IDENTITY) == "m/1075233755'/1660078172'/0'/0'/0'/0'" assert muse_path(DOMAIN_MUSIC, entity_type=ENTITY_AGENT, entity_id=1) \ == "m/1075233755'/1755707987'/1'/1'/0'/0'" """ _validate_non_negative(domain, "domain") _validate_entity_type(entity_type) _validate_non_negative(entity_id, "entity_id") _validate_role(role) _validate_non_negative(index, "index") return ( f"m/{MUSE_PURPOSE}'" f"/{domain}'" f"/{entity_type}'" f"/{entity_id}'" f"/{role}'" f"/{index}'" ) # --------------------------------------------------------------------------- # Core derivation # --------------------------------------------------------------------------- def derive_key( seed: bytes, domain: int, entity_type: int = ENTITY_HUMAN, entity_id: int = 0, role: int = ROLE_SIGN, index: int = 0, ) -> DerivedKey: """Derive an Ed25519 key at the given Muse coordinates. This is the general-purpose derivation primitive. For common cases prefer :func:`derive_identity_key` or :func:`derive_domain_key` — they call this internally with clearer call sites. Path derived:: m / purpose' / domain' / entity_type' / entity_id' / role' / index' Parameters ---------- seed: 64-byte BIP39 seed from :func:`muse.core.bip39.mnemonic_to_seed`, or a 64-byte agent sub-seed from :func:`derive_agent_sub_seed`. domain: Domain index (``DOMAIN_*`` constant). entity_type: Entity class (``ENTITY_*`` constant). Default: :data:`ENTITY_HUMAN`. entity_id: Entity slot within its type. Default: ``0`` (first). role: Key role within the domain (``ROLE_*`` constant). Default: :data:`ROLE_SIGN`. index: Key rotation index. 0 = current, 1 = pre-rotated. Default: ``0``. Returns ------- DerivedKey SLIP-0010 private key and chain code at the requested coordinates. Raises ------ HdKeyError If any argument is out of range. Slip010Error If *seed* is shorter than 16 bytes. Examples -------- :: # Human operator's music signing key dk = derive_key(seed, domain=DOMAIN_MUSIC) # Agent slot 2's identity key dk = derive_key(seed, domain=DOMAIN_IDENTITY, entity_type=ENTITY_AGENT, entity_id=2) """ path = muse_path(domain, entity_type, entity_id, role, index) return derive_path(seed, path) def derive_identity_key( seed: bytes, entity_type: int = ENTITY_HUMAN, entity_id: int = 0, index: int = 0, ) -> DerivedKey: """Derive the cross-domain identity (MSign auth) key. The identity key is the entity's **passport** on the Muse network. It is used for: - Ed25519 HTTP request signing (``X-Muse-Signature`` header) - MuseHub authentication and handle registration - Agent identity in agentception This key belongs to :data:`DOMAIN_IDENTITY` and uses :data:`ROLE_SIGN`. It is the same key regardless of which domain the entity is working in — authentication is a cross-domain concern. Parameters ---------- seed: 64-byte BIP39 seed or agent sub-seed. entity_type: Entity class. Default: :data:`ENTITY_HUMAN`. entity_id: Entity slot. Default: ``0``. index: Rotation index. Default: ``0`` (current key). Returns ------- DerivedKey Identity key at ``m/purpose'/0'/entity_type'/entity_id'/0'/index'``. Examples -------- :: # Human operator's MuseHub auth key dk = derive_identity_key(seed) priv = dk_to_ed25519(dk) pub = priv.public_key().public_bytes_raw() # register this with MuseHub # Agent slot 0's identity key (auth_agent_seed from derive_agent_sub_seed) agent_dk = derive_identity_key(auth_agent_seed, entity_type=ENTITY_AGENT) """ return derive_key( seed, domain=DOMAIN_IDENTITY, entity_type=entity_type, entity_id=entity_id, role=ROLE_SIGN, index=index, ) def derive_domain_key( seed: bytes, domain: int, entity_type: int = ENTITY_HUMAN, entity_id: int = 0, role: int = ROLE_SIGN, index: int = 0, ) -> DerivedKey: """Derive a domain-specific signing key. Use this for keys that sign *content* within a particular domain — commit provenance, music project signatures, payment claims, etc. The identity key (:func:`derive_identity_key`) handles *authentication*; this function handles *attestation*. Parameters ---------- seed: 64-byte BIP39 seed or agent sub-seed. domain: Domain index (``DOMAIN_CODE``, ``DOMAIN_MUSIC``, etc.). :data:`DOMAIN_IDENTITY` is valid but :func:`derive_identity_key` is preferred for that case. entity_type: Entity class. Default: :data:`ENTITY_HUMAN`. entity_id: Entity slot. Default: ``0``. role: Key role within the domain. Default: :data:`ROLE_SIGN`. index: Rotation index. Default: ``0``. Returns ------- DerivedKey Domain key at the requested coordinates. Examples -------- :: # Human operator's commit signing key code_dk = derive_domain_key(seed, domain=DOMAIN_CODE) # Human operator's Stori project signing key music_dk = derive_domain_key(seed, domain=DOMAIN_MUSIC) # Human operator's MPay payment key pay_dk = derive_domain_key(seed, domain=DOMAIN_PAYMENTS, role=ROLE_RECEIVE) """ return derive_key(seed, domain, entity_type, entity_id, role, index) def derive_agent_sub_seed( seed: bytes, domain: int, agent_id: int, ) -> bytearray: """Derive a 64-byte domain-scoped sub-seed for an agent. Agent processes must never receive the operator's master seed. Instead, the operator derives a **per-domain, per-agent sub-seed** and injects it into the agent process. The agent treats this sub-seed exactly like a regular BIP39 seed — it calls :func:`derive_identity_key` and :func:`derive_domain_key` with it as normal. The sub-seed is rooted at:: m / purpose' / domain' / ENTITY_AGENT' / agent_id' So the agent's key tree sits entirely within the operator's ``domain`` sub-tree. The agent physically cannot derive keys in any other domain, nor can it derive the operator's keys — SLIP-0010 hardened derivation guarantees this. Sub-seed composition:: sub_seed = dk.private_bytes + dk.chain_code # 64 bytes Both halves are required: the chain code enables further child derivation. Parameters ---------- seed: Master 64-byte BIP39 seed (operator's seed). domain: The domain to scope this agent to. The agent can only derive keys within this domain from the returned sub-seed. agent_id: Agent slot index within the domain. Must be >= 0. Returns ------- bytes 64-byte domain-scoped sub-seed. Treat with the same care as the master seed — never log it, store it unencrypted, or transmit over an unauthenticated channel. Raises ------ HdKeyError If *domain* or *agent_id* is negative. Slip010Error If *seed* is shorter than 16 bytes. Security -------- Each (domain, agent_id) pair produces a unique, independent sub-seed. Granting multiple domain sub-seeds to an agent (for cross-domain capability) is done at the orchestration layer (agentception) by injecting multiple sub-seeds — never by combining them or using the master seed. Examples -------- :: # Music composition agent — scoped to music domain only music_seed = derive_agent_sub_seed(seed, domain=DOMAIN_MUSIC, agent_id=0) # Same agent also needs to authenticate — inject identity sub-seed separately auth_seed = derive_agent_sub_seed(seed, domain=DOMAIN_IDENTITY, agent_id=0) # Agent uses each seed for its respective domain agent_identity_dk = derive_identity_key(auth_seed) agent_music_dk = derive_domain_key(music_seed, domain=DOMAIN_MUSIC) """ _validate_non_negative(domain, "domain") _validate_non_negative(agent_id, "agent_id") # Root: m / purpose' / domain' / ENTITY_AGENT' / agent_id' dk = master_key(seed) for hardened_index in [hardened(MUSE_PURPOSE), hardened(domain), hardened(ENTITY_AGENT), hardened(agent_id)]: next_dk = child_key(dk, hardened_index) dk.zero() dk = next_dk sub_seed = SecretByteArray(dk.private_bytes) + bytearray(dk.chain_code) dk.zero() return SecretByteArray(sub_seed) # --------------------------------------------------------------------------- # Key materialisation helpers # --------------------------------------------------------------------------- def dk_to_ed25519(dk: DerivedKey) -> "Ed25519PrivateKey": """Materialise a :class:`~muse.core.slip010.DerivedKey` as an Ed25519 signing key. Thin re-export of :func:`muse.core.slip010.to_ed25519_private_key` so callers can use a single import from this module:: from muse.core.hdkeys import derive_identity_key, dk_to_ed25519 Parameters ---------- dk: Derived key from any ``derive_*`` function in this module. Returns ------- Ed25519PrivateKey ``cryptography`` library signing key. Call ``.sign(message)`` to produce an Ed25519 signature, and ``.public_key().public_bytes_raw()`` for the 32-byte public key. Examples -------- :: dk = derive_identity_key(seed) priv = dk_to_ed25519(dk) sig = priv.sign(b"hello muse") priv.public_key().verify(sig, b"hello muse") # does not raise → valid """ return to_ed25519_private_key(dk) def public_bytes_from_seed( seed: bytes, domain: int = DOMAIN_IDENTITY, entity_type: int = ENTITY_HUMAN, entity_id: int = 0, role: int = ROLE_SIGN, index: int = 0, ) -> bytes: """Return the raw 32-byte Ed25519 public key at the given Muse coordinates. Convenience one-liner for callers that only need the public key — e.g. to display a fingerprint or register with MuseHub. Parameters ---------- seed: 64-byte BIP39 seed or agent sub-seed. domain: Domain index. Default: :data:`DOMAIN_IDENTITY`. entity_type: Entity class. Default: :data:`ENTITY_HUMAN`. entity_id: Entity slot. Default: ``0``. role: Key role. Default: :data:`ROLE_SIGN`. index: Rotation index. Default: ``0``. Returns ------- bytes 32-byte raw Ed25519 public key. Examples -------- :: pub = public_bytes_from_seed(seed) # identity key public bytes fingerprint = pub.hex()[:16] # short display fingerprint assert len(pub) == 32 """ dk = derive_key(seed, domain, entity_type, entity_id, role, index) try: private_key = dk_to_ed25519(dk) finally: dk.zero() return private_key.public_key().public_bytes_raw() # --------------------------------------------------------------------------- # Internal validators # --------------------------------------------------------------------------- def _validate_non_negative(value: int, name: str) -> None: if value < 0: raise HdKeyError(f"{name} must be >= 0; got {value}.") def _validate_entity_type(entity_type: int) -> None: if entity_type < 0 or entity_type > 2: raise HdKeyError( f"entity_type must be 0 (ENTITY_HUMAN), 1 (ENTITY_AGENT), " f"or 2 (ENTITY_ORG); got {entity_type}." ) def _validate_role(role: int) -> None: if role < 0 or role > 4: raise HdKeyError( f"role must be 0 (ROLE_SIGN), 1 (ROLE_RECEIVE), 2 (ROLE_PROVISION), " f"3 (ROLE_ATTEST), or 4 (ROLE_DELEGATE); got {role}." )