Developer Docs Cryptographic Identity
PHASE 02

Cryptographic Identity

Every actor in the Muse ecosystem — human, agent, or organisation — has a cryptographic identity derived from a single HD wallet seed using SLIP-0010. There are no passwords, no JWTs, no OAuth flows. Every HTTP request is signed with Ed25519 and verified at the hub before any data is touched. In addition to humans, agents and organizations are first-class citizens with their own derived keys, and every commit carries a tamper-evident provenance chain traceable back to a root mnemonic.

Key model

Muse uses Ed25519 (RFC 8032) throughout. Every human identity is backed by a BIP-39 mnemonic — the root of an HD wallet. Child keys are derived deterministically using SLIP-0010 (the standard for non-secp256k1 curves), so an agent spawned from a human inherits a key derived from that human's seed at a specific HD path. You can rotate, recover, and audit the entire chain from one secret.

Derivation path

Muse's HD path has six levels. Every level answers exactly one question:

text HD path structure
m / purpose' / domain' / entity_type' / entity_id' / role' / index'

The purpose' level is 1075233755'int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF — a hardened namespace that separates Muse keys from every other HD wallet application at the root. All six levels use hardened derivation ('), so no child key ever exposes its siblings or parent.

muse auth show --json prints your full HD identity — path decoded, public key, fingerprint, and hub registration timestamp:

bash show full HD identity
muse auth show --json
json output
{
  "hub":                 "staging.musehub.ai",
  "handle":              "gabriel",
  "type":                "human",
  "algorithm":           "ed25519",
  "fingerprint":         "sha256:a3f2c9d8e1b47f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
  "hd_path":             "m/1075233755'/1660078172'/0'/0'/0'/0'",
  "mnemonic_word_count": 24,
  "derived_paths": {
    "identity_msign": "m/1075233755'/1660078172'/0'/0'/0'/0'",
    "payments_mpay":  "m/1075233755'/284229149'/0'/0'/0'/0'",
    "avax_c_chain":   "m/44'/60'/0'/0/0",
    "agent_slot_0":   "m/1075233755'/1660078172'/1'/0'/0'/0'"
  },
  "avax_c_chain_address": "0x3f8a1c2d…"
}

Domain namespace

The domain' level is the atom. Everything else — entities, keys, roles — exists within a domain. The namespace is open by design: new domains plug in without touching the schema.

IndexDomainUse
1660078172'IdentityMSign auth, MuseHub registration, cross-domain self
284229149'PaymentsMPay claims, financial settlement
678195575'CodeCommit provenance, software VCS
915186137'MistContent-addressed artifact hosting
1755707987'MusicStori, audio production signing
1444628350'MIDIMaestro, symbolic music
1658731548'ProseDocuments, markdown, long-form
1556829714'BlockchainOn-chain operations — ERC8004, EVM, AVAX
2023564266'GenericUntyped / catch-all (reserved)
(open)Future domains extend the table, not the schema

Domain 1660078172' (Identity) is the special case — it is the key that crosses all other domains. When Gabriel logs into MuseHub, that is domain=1660078172'. When Gabriel signs a commit, that is domain=678195575'. Same entity, different domain keys, provably isolated.

Domain integers are computed as int.from_bytes(sha256(name.encode())[:4], "big") & 0x7FFFFFFF — a stable, collision-resistant value derived from the canonical domain name string. Use muse domain index <name> to compute or look up any domain integer offline.

Principle of least privilege — encoded in the key tree

Because domain is a structural level in the derivation path, agent delegation is cryptographically scoped, not policy-scoped. An agent sub-seed derived at domain=284229149' can only produce keys within the Payments domain. It physically cannot derive an Identity key or a Code key — not because a rule forbids it, but because the math does not connect those branches.

The three entity types map directly onto the derivation path:

IndexEntity typeDescription
0'HumanA person with a root mnemonic. Account 0 is always the primary identity.
1'AgentAny non-human principal delegated from a human or org — LLM agents, CI runners, daemons. All the same at the key level.
2'OrgA collective identity. Membership and governance live above the key layer; the tree records only that this principal is a collective.

There is no separate service or daemon type — those are agents. Quorum is not an entity type either; it is a threshold policy that references multiple paths at verification time. The key tree stays flat and complete.

python Domain-scoped agent delegation
# Music composition agent — can only derive Music domain keys
music_agent_seed = derive_sub_seed(master_seed, domain=1755707987, entity_type=1, agent_id=0)

# Code review agent — can only derive Code domain keys
code_agent_seed  = derive_sub_seed(master_seed, domain=678195575, entity_type=1, agent_id=0)

# Agentception orchestrator — Identity domain only, nothing else
auth_agent_seed  = derive_sub_seed(master_seed, domain=1660078172, entity_type=1, agent_id=0)

The mnemonic lives in ~/.muse/identity.toml (in memory only — never written to disk after initial setup). Each derived key is persisted as a PEM file alongside its fingerprint. Agentception injects agent sub-seeds into spawned agents via the MUSE_AGENT_KEY_FD file descriptor — a 64-byte raw sub-seed passed over a pipe — so the agent's process never touches the root mnemonic.

ml-dsa-65 (CRYSTALS-Dilithium) is reserved for post-quantum migration. The algorithm prefix in every ID and signature string (ed25519:…, mldsa65:…) means the format is self-describing — old and new keys coexist without a flag day.

MSign authentication

Every authenticated request to MuseHub carries an MSign Authorization header. No session cookies, no bearer tokens. The hub verifies the signature on every request — stateless, replay-protected, and auditable.

http MSign header format
Authorization: MSign handle="gabriel" alg="ed25519" ts=1744000000 sig="<base64url>"

What gets signed

The signature covers a canonical message assembled from the request's algorithm, method, host, path, timestamp, and body hash — bound together with newlines:

text canonical message (newline-separated, UTF-8)
{algorithm}
{METHOD}
{host}
{path_with_query}
{unix_timestamp}
{hex(sha256(request_body))}
text example — pushing to gabriel/muse
ed25519
POST
staging.musehub.ai
/gabriel/muse/push
1744000000
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
FieldValueNotes
algorithmed25519Matches alg= in header
METHODPOST, GET, …Uppercase
hoststaging.musehub.aiLowercase; strip standard ports (443/80)
path_with_query/gabriel/muse/pushInclude ?query if present
unix_timestamp1744000000Seconds since epoch; replay window ±30 s
body_hash64-char lowercase hexSHA-256 of raw request body; empty body → SHA-256 of b""

Signing a request

bash muse sign — produce or execute a signed request
# Produce an Authorization header for a POST
muse sign header \
  --method POST \
  --path /gabriel/muse/push \
  --hub http://staging.musehub.ai \
  --json

# Sign and execute in one command
muse sign request \
  --method POST \
  --url http://staging.musehub.ai/gabriel/muse/push \
  --body-file payload.msgpack \
  --json

# Verify a header (useful in tests)
muse sign verify \
  --header 'MSign handle="gabriel" alg="ed25519" ts=1744000000 sig="..."' \
  --method POST \
  --url http://staging.musehub.ai/gabriel/muse/push \
  --public-key-b64 <base64url-pubkey>
python signing programmatically
from muse.core.msign import sign_request, verify_request
import httpx, time

# sign_request returns the Authorization header value
auth_header = sign_request(
    method="POST",
    url="http://staging.musehub.ai/gabriel/muse/push",
    body=payload_bytes,
    private_key=ed25519_private_key,  # cryptography.hazmat PrivateKey
    handle="gabriel",
)

resp = httpx.post(
    "http://staging.musehub.ai/gabriel/muse/push",
    content=payload_bytes,
    headers={"Authorization": auth_header,
             "Content-Type": "application/x-msgpack"},
)

identity.toml

All identity state for a host lives in ~/.muse/identity.toml. Each section is keyed by host[:port] for human entries, or host[:port]#handle for agent entries. One file, one source of truth — no per-repo credential config.

toml ~/.muse/identity.toml
# Human entry — one per hub
["localhost:10003"]
type        = "human"
handle      = "gabriel"
algorithm   = "ed25519"
fingerprint = "sha256:a3f2c9d8..."   # sha256:<64-hex> fingerprint of public key
hd_path     = "m/1075233755'/1660078172'/0'/0'/0'/0'"  # SLIP-0010 derivation path

# Agent entry — provisioned by gabriel on this hub
["localhost:10003#claude-code"]
type                     = "agent"
handle                   = "claude-code"
algorithm                = "ed25519"
fingerprint              = "sha256:b7e1a4c2..."
hd_path                  = "m/1075233755'/1660078172'/1'/0'/0'/0'"
provisioned_by           = "gabriel"
provisioned_by_fingerprint = "sha256:a3f2c9d8..."
capabilities             = ["push", "pull", "commit"]
FieldTypePresent onDescription
typestringall"human" or "agent"
handlestringallHub-assigned username
algorithmstringall"ed25519" (reserved: "ml-dsa-65")
fingerprintstringallsha256:<64-hex> fingerprint of the public key
hd_pathstringallSLIP-0010 derivation path, e.g. "m/0'"
provisioned_bystringagentHandle of the human who spawned this agent
provisioned_by_fingerprintstringagentFingerprint of the provisioning key
capabilitieslistagentAllowed operations: "push", "pull", "commit", …
mnemonicstringhuman (memory only)BIP-39 phrase; injected at startup, never persisted

Cryptographic value codec

Every cryptographic value is always canonically prefixed. Never a bare hex string or bare base64 blob. The prefix embeds the algorithm, making every value self-describing across storage, wire, and logs.

text wire formats
sha256:<64-hex>       — content-addressed object / commit / snapshot ID
ed25519:<base64url>   — Ed25519 signature or public key (no padding)
mldsa65:<base64url>   — post-quantum signature (reserved)
python muse.core.types — codec functions
# Split without decoding — use when you need the string form
split_id("sha256:abc...")      → ("sha256", "abc...")
split_sig("ed25519:AAA...")  → ("ed25519", "AAA...")
split_pubkey("ed25519:BBB...") → ("ed25519", "BBB...")

# Decode to raw bytes — use for cryptographic operations only
decode_sig("ed25519:AAA...")    → ("ed25519", b"...")
decode_pubkey("ed25519:BBB...") → ("ed25519", b"...")

# Encode raw bytes to prefixed string
encode_sig("ed25519", raw_bytes)    → "ed25519:<base64url>"
encode_pubkey("ed25519", raw_bytes) → "ed25519:<base64url>"

# Just the algo name
sig_algo("ed25519:AAA...") → "ed25519"
Never strip prefixes manually — no .removeprefix("ed25519:"), no [10:], no inline .partition(":"). Always use split_sig / split_pubkey / split_id. Never store or transmit bare bytes — always encode with encode_sig / encode_pubkey before serialisation.

Trust chains

The identity graph is a DAG with three node types and two edge types. Humans are the only root nodes — they derive authority from their key pair. Agents are provisioned by (spawned from) humans or other agents. Organisations exist as collective entities whose authority is the quorum of their members.

text identity DAG — node and edge types
Nodes:  HUMAN  AGENT  ORG

Edges:  spawns(from, to)        — from provisions to (human→agent, agent→agent)
        member_of(member, org) — member joins org (human, agent, or org → org)

Example trust chain:
  gabriel (HUMAN)
    ├─[spawns]──▶ claude-code (AGENT)
    │               └─[spawns]──▶ worker-7 (AGENT)
    ├─[member_of]──▶ musehub-org (ORG, quorum=1)
    └─[member_of]──▶ graph-lab (ORG, quorum=2)
                        ├─◀── gabriel
                        ├─◀── claude-code
                        └─◀── musehub-org

The graph is append-only and enforced at push time by IdentityPushValidator on the hub side. Three invariants must hold on every push — in order, hard errors before warnings:

InvariantNameEnforcementEffect on push
I1Acyclicity hard error Push rejected; offending edge listed in errors
I2Root distance warning Push accepted; orphaned node annotated
I3Authorization hard error Push rejected; missing signature listed

Attestations

An attestation is an Ed25519-signed claim one identity makes about another — the same key material, the same primitives, and the same canonical-prefix discipline as the rest of this page. The claim is content-addressed by the SHA-256 of its canonical message, verified entirely against the attester's registered public key, and preserved forever in the audit trail. Attestations are how the identity DAG above acquires semantic edges — beyond spawns and member_of, an attestation can record that one identity reviewed another's code, approved a master, verified a skill, or simply trusts them.

Canonical message

The ATTEST domain separator is the entire reason cross-protocol replay is impossible. An MSign auth header signed under MUSE-SIGN-V1 can never be replayed as an attestation, and an MPay claim signed under MPAY can never be replayed as a code review. The canonical message is UTF-8, newline-separated, with no trailing newline:

text identity scope — 5 lines
ATTEST\n{attester}\n{subject}\n{claim}\n{issued_at_iso}
text repo / commit scope — 6 lines (scope_ref appended)
ATTEST\n{attester}\n{subject}\n{claim}\n{issued_at_iso}\n{scope_ref}

{claim} is a compact JSON object whose top-level "type" key must match a registered claim type. Compactness is enforced by serialising with json.dumps(claim, separators=(",", ":"), sort_keys=True) so the same logical claim always produces the same bytes.

python building and signing the canonical message
# Identity scope — 5 lines
parts = ["ATTEST", attester, subject, claim_json, issued_at_iso]

# Repo / commit scope — 6 lines
if scope != "identity":
    parts.append(scope_ref)

msg = "\n".join(parts).encode("utf-8")
signature = privkey.sign(msg)                     # Ed25519, RFC 8032
sig_str   = encode_sig("ed25519", signature)      # → "ed25519:<base64url>"

The content-addressed identifier is independent of the signature — two attestations with the same canonical message collapse to the same row, which is why create is idempotent:

text attestation_id derivation
attestation_id = sha256(attester NUL subject NUL claim NUL issued_at_iso)

The 17 claim types

Every attestation declares exactly one claim type. The registry is seeded in musehub.services.musehub_attestations and mirrored to the musehub_attestation_claim_types table — the DB is authoritative at runtime and accepts new types via add_claim_type, but every node ships with the same 17 seed entries below. Each type pre-declares which scopes it makes sense in; mismatches are rejected before any signature is verified.

CategoryType keyLabelValid scopesMeaning
identityhuman Human identity Subject is a verified human.
org Organisation identity Subject is a legitimate organisation.
agent Agent identity Subject is a trustworthy agent.
trust spawned-by Spawned By identity Subject agent was provisioned by attester.
delegate Delegate identity Attester delegated authority to subject.
trusted Trusted identity Attester generally trusts subject.
collab collab Collaborator identity, repo, commit Attester and subject collaborated.
co-author Co-author identity, repo, commit Subject co-authored something with attester.
contractor Contractor identity Subject performed contracted work for attester.
code code:reviewed Code Reviewed commit, repo Attester reviewed subject's code.
code:approved Code Approved commit, repo Attester approved a specific code delivery.
deploy:approved Deploy Approved commit Attester approved a deployment at this commit.
music stems:verified Stems Verified identity, commit Attester verified the authenticity of subject's stems.
mix:approved Mix Approved identity, commit Attester approved a mix by subject.
midi:generated MIDI Generated identity, commit Attester confirms subject generated the MIDI.
master:approved Master Approved identity, commit Attester approved a master by subject.
skill skill:verified Skill Verified identity Attester verified a declared skill of subject.
Use --metadata '{"key":"value"}' on attestation create to attach extra fields inside the claim JSON — e.g. {"type":"skill:verified","skill":"counterpoint"}. Anything you put in the metadata becomes part of the canonical message and is therefore covered by the signature; rewrite it after the fact and the signature breaks.

The three scopes

Scope answers "what is this attestation about?" The scope is part of the canonical message via scope_ref, which means an attestation that says "I reviewed gabriel/musehub at commit sha256:abc…" cannot be replayed as "I reviewed gabriel/musehub at commit sha256:def…" — the bytes don't match and the signature fails.

Scopescope_ref formatRequired fieldsUse for
identity — (omitted) Claims about a handle as a whole — humanity, org status, trust, skill verification.
repo {handle}/{repo_slug} scope_ref Claims about a specific repository — collaboration, ongoing review.
commit {handle}/{repo_slug}@sha256:{commit_id} scope_ref, commit_id Claims about a specific delivery — code review, deploy approval, master sign-off.

The validator runs in this order, and stops at the first failure: (1) claim type exists and is not deprecated; (2) scope is in the claim type's valid_scopes; (3) required scope fields are present; (4) Ed25519 signature verifies against the attester's registered public key. Cheap checks first; cryptography last.

CLI commands

TaskCommand
Issue an identity-scope attestationmuse hub attestation create --subject <handle> --type <claim_type>
Issue a repo-scope attestationmuse hub attestation create --subject <handle/repo> --type <claim_type> --scope repo --scope-ref <handle/repo>
Issue a commit-scope attestationmuse hub attestation create --subject <handle/repo> --type <claim_type> --scope commit --scope-ref <handle/repo@sha256:…> --commit-id <sha256:…>
Attach extra claim fieldsmuse hub attestation create … --metadata '{"skill":"counterpoint"}'
Issue with an expirymuse hub attestation create … --expires-in 90d
List attestations about a subjectmuse hub attestation list --subject <handle> --json
Filter list by attestermuse hub attestation list --subject <handle> --attester <handle> --json
Filter list by claim typemuse hub attestation list --subject <handle> --type <claim_type> --json
Include revoked entriesmuse hub attestation list --subject <handle> --include-revoked --json
Revoke (only the original attester)muse hub attestation revoke <attestation_id> --subject <handle>
bash end-to-end example
# gabriel attests that claude-code is a trustworthy agent
muse hub attestation create --subject claude-code --type agent --json

# gabriel reviews a specific commit on his own repo
muse hub attestation create \
  --subject gabriel/musehub --type code:reviewed \
  --scope commit \
  --scope-ref gabriel/musehub@sha256:abc123... \
  --commit-id sha256:abc123... \
  --json

# list everything attested about gabriel
muse hub attestation list --subject gabriel --json

# revoke an old attestation (only the original attester can do this)
muse hub attestation revoke sha256:af54753d... --subject claude-code --json

The CLI loads the private key from ~/.muse/identity.toml (or the agent sub-key injected via MUSE_AGENT_KEY_FD), builds the canonical message, signs it locally, and POSTs the signature plus public key to /api/profiles/{handle}/attestations. The hub never sees the private key — it only verifies the signature against the public key it already has on file from the attester's registration.

REST API

MethodPathDescription
GET /api/profiles/{handle}/attestations List attestations about {handle} (add ?include_revoked=true to include retired entries).
POST /api/profiles/{handle}/attestations Issue a new attestation. Body is AttestationRequest. Idempotent on (attester, subject, claim, issued_at).
DELETE /api/profiles/{handle}/attestations/{id}?revoker={handle} Soft-revoke. Sets revoked_at; row is preserved for audit. Only the original attester succeeds.
GET /api/profiles/attestation-types List all registered claim types (add ?include_deprecated=true for retired entries).
Revocation is soft — the row is kept with a revoked_at timestamp set, never deleted. This is by design: downstream consumers may have already cached or relied on the attestation, and the audit trail is what makes the system tamper-evident. Default queries exclude revoked rows; pass --include-revoked or ?include_revoked=true to see them.

The three invariants

I1 — Acyclicity

The identity graph must be a DAG at all times. Before accepting any new edge from → to, the validator runs a DFS from to and rejects the push if it reaches from. Self-loops (from == to) are rejected immediately without traversal. Both spawns and member_of edges share the same DAG universe — a cross-type cycle (e.g. alice →spawns→ bot →member_of→ alice) is equally rejected.

I2 — Root distance

Every node should have a path to at least one human root. The validator runs a multi-source BFS from all HUMAN nodes and reports any node with no path as an orphan. An orphaned agent or org is not necessarily a bug — the push is accepted — but the node is flagged in ValidationResult.warnings for the caller to inspect.

I3 — Authorization

Every relationship must carry cryptographic signatures proving consent. The rules differ by edge type:

Edge typeRequired signersNotes
spawns(A → B) A must sign Only the spawner can authorise the spawn. Extra signers are permitted.
member_of(M → Org) — founding M must self-sign Org has zero prior members. Bootstrap pattern.
member_of(M → Org) — subsequent min(quorum, |prior|) existing members Processed in input order. Each relationship sees only members committed before it.
python IdentityPushValidator
from musehub.graph.push_validator import IdentityPushValidator

validator = IdentityPushValidator()
result = validator.validate(
    identities=[
        {"handle": "gabriel",   "type": "human", "quorum": None},
        {"handle": "claude-code", "type": "agent", "quorum": None},
        {"handle": "acme",        "type": "org",   "quorum": 2},
    ],
    relationships=[
        {
            "from_handle": "gabriel",
            "to_handle":   "claude-code",
            "edge_type":   "spawns",
            "authorized_by": [
                {"signer": "gabriel", "signature": "ed25519:...", "signed_at": "2026-04-21T..."}
            ],
        },
    ],
)
# result.valid → True / False
# result.errors → ["I1 violation: ...", "I3 violation: ..."]
# result.warnings → ["I2 warning: 'acme' has no path to any human root"]

Quorum authorization

Organisations have no key of their own. Their authority is the quorum of their members — distributed threshold authorisation rather than a single key that can be stolen or lost. The quorum field on an org record sets the threshold; members carry fractional weight values so voting power can be non-uniform.

Relationships are processed in input order — each one sees only the members committed before it in the same push. This mirrors an append-only event log and solves the bootstrap problem cleanly: the first member of an empty org self-authorises; subsequent members need min(quorum, |prior|) existing member signatures.

text quorum scenarios — graph-lab (quorum=2)
Step 1: gabriel joins graph-lab (0 prior members)
  → bootstrap: gabriel self-signs               ✓  required: 1 sig (self)

Step 2: claude-code joins graph-lab (1 prior member: gabriel)
  → min(quorum=2, prior=1) = 1                  ✓  required: 1 sig from {gabriel}

Step 3: musehub-org joins graph-lab (2 prior members: gabriel, claude-code)
  → min(quorum=2, prior=2) = 2                  ✓  required: 2 sigs from {gabriel, claude-code}

Step 4: alice tries to join (3 prior members: gabriel, claude-code, musehub-org)
  → min(quorum=2, prior=3) = 2                  ✓  required: any 2 of the 3 existing members

Sub-organisations can vote in their own member_of relationships, but only if their own quorum is independently met. The QuorumEngine.effective_weight() method descends recursively to validate that an org's vote is backed by sufficient member signatures before counting it toward the parent org's threshold.

python musehub.graph.quorum
from musehub.graph.quorum import OrgSpec, QuorumEngine, VoteRecord
from decimal import Decimal

orgs = {
    "graph-lab": OrgSpec(
        handle="graph-lab",
        quorum=2,
        member_weights={
            "gabriel":     Decimal("1"),
            "claude-code": Decimal("1"),
        },
    ),
}
engine = QuorumEngine(orgs)

votes = [
    VoteRecord(voter_handle="gabriel",     org_handle="graph-lab"),
    VoteRecord(voter_handle="claude-code", org_handle="graph-lab"),
]
engine.is_quorum_met("graph-lab", votes)  # → True

Worked 2-of-3 example — admitting a new member

The hub verifies quorum at push time by inspecting the authorized_by array on each relationship. Here is the full push payload for admitting alice to graph-lab (quorum=2, three prior members):

json identity push payload — 2-of-3 multi-sig membership
{
  "identities": [
    { "handle": "alice", "type": "human", "quorum": null,
      "public_key": "ed25519:aL1mN2oP3qR4sT5uV6wX7yZ8aB9cD0eF1gH2iJ3kL4" }
  ],
  "relationships": [
    {
      "from_handle": "alice",
      "to_handle":   "graph-lab",
      "edge_type":   "member_of",
      "authorized_by": [
        {
          "signer":     "gabriel",
          "signature": "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9",
          "signed_at": "2026-04-21T16:00:00Z"
        },
        {
          "signer":     "claude-code",
          "signature": "ed25519:Yf4lB8nCdD3eF6gH9iJ2kL5mN7oP1qR4sT7uV0wX3",
          "signed_at": "2026-04-21T16:01:00Z"
        }
      ]
    }
  ]
}
json hub response — valid push
{
  "valid":    true,
  "errors":   [],
  "warnings": []
}

If only one signature is provided — below the quorum threshold — the hub rejects the push immediately:

json hub response — quorum not met
{
  "valid":    false,
  "errors":   [
    "I3 violation: member_of(alice → graph-lab) requires 2 signatures from existing members, got 1"
  ],
  "warnings": []
}

Quorum in governance.json — handle-based members

Repo governance uses the same quorum concept. A repo is governed when its HEAD snapshot contains a governance.json file. The hub resolves member handles to fingerprints at merge time by reading each member's identity repo HEAD — so key rotation propagates automatically without updating governance.json.

json governance.json — handle-based members (recommended)
{
  "quorum": {
    "threshold": 2,
    "members": ["gabriel", "alice"]
  }
}

Raw sha256: fingerprints are also accepted for backward compatibility, but handles are preferred — they track key rotation without any update to the file.

json governance.json — raw fingerprints (legacy / backward compat)
{
  "quorum": {
    "threshold": 2,
    "members": ["sha256:a3f2c9d8...", "sha256:b7e1a4c2..."]
  }
}

Handle resolution reads identities/{handle}.json from the member's identity repo HEAD, decodes the pubkey field, and derives the sha256: fingerprint. If the identity repo is absent or the handle cannot be resolved, that member is skipped and cannot contribute to the quorum.

Auth CLI

TaskCommand
Generate new keypairmuse auth keygen --hub <hub-url>
Register key on hubmuse auth register --hub <url> --handle <handle>
Show current identitymuse auth whoami --json
Full HD identity detailsmuse auth show --json
Rotate to next HD indexmuse auth rotate --hub <url> --json
Recover key from mnemonicmuse auth recover --hub <url> --json
Decommission (logout)muse auth logout --hub <url> --json
Decode an HD path to human-readable labelsmuse path annotate <hd-path> --json
Migrate pre-Phase-1 keys to hash-derived pathsmuse migrate domain-integers --json
Dry-run migration (no writes)muse migrate domain-integers --dry-run --json
bash first-time setup
# 1. Generate keypair — outputs a BIP-39 mnemonic; store it securely
muse auth keygen --hub http://staging.musehub.ai

# 2. Register the public key with the hub
muse auth register --hub http://staging.musehub.ai --handle gabriel

# 3. Verify
muse auth whoami
# → handle: gabriel  hub: <your-hub-host>  algo: ed25519
Agentception spawns agents by passing a 64-byte sub-seed via MUSE_AGENT_KEY_FD (a pipe file descriptor) and the agent's handle via MUSE_AGENT_HANDLE. The agent's get_signing_identity() reads the fd first, derives its Ed25519 key via SLIP-0010, then falls back to ~/.muse/identity.toml. The root mnemonic never leaves the parent process.

Key rotation

Three distinct values describe a Muse cryptographic identity. Confusing them is the most common source of auth bugs — especially after key rotation.

FieldDerived fromChanges on rotation?Where it lives
identity_id sha256(first_registered_key_bytes) Never — immutable anchor musehub_identities.identity_id
fingerprint sha256(current_key_bytes) Yes — new value per rotation musehub_auth_keys.fingerprint
public_key_b64 Raw 32-byte Ed25519 public key, base64url (no padding) Yes — new value per rotation musehub_auth_keys.public_key_b64

identity_id is computed once when you first register and stored in musehub_identities. It is the stable anchor for your identity — every repo you create, every commit you sign, and every key you ever rotate to is linked back to this single value. It never changes.

fingerprint and public_key_b64 belong to individual keys, not the identity. They live in musehub_auth_keys, one row per registered key. An identity can have many registered keys simultaneously — one per device, one per agent, one from before a rotation. Deleting a row immediately revokes that key.

muse auth keygen vs muse auth rotate

These two commands look similar but have fundamentally different effects:

muse auth keygenmuse auth rotate
What it does Generates a new BIP-39 mnemonic, derives key at index 0 Derives the next HD index from the existing mnemonic
identity_id New value — a new identity Unchanged — same identity
If handle is already registered 409 Conflict — handle is taken Succeeds — adds a new key row to the existing identity
Old key after the operation Still registered (on the old identity) Still valid until you explicitly revoke it
Use when First-time setup only, or you intentionally want a fresh identity Routine rotation, machine migration, compromise response
Running muse auth keygen when you already have a registered handle will not replace your existing identity. It generates a completely new mnemonic and a new identity_id. Attempting to register the new key under your existing handle returns 409 Conflict. Use muse auth rotate for key rotation.

Rotation flow

bash rotate to the next HD index
# Derive next key, register it with the hub, update identity.toml
muse auth rotate --hub http://staging.musehub.ai --json

# identity_id is unchanged. A second row now exists in musehub_auth_keys.
# The old key is still valid. Revoke it once you confirm the new key works:
muse auth whoami --json           # confirm new fingerprint is active
muse auth logout --hub http://staging.musehub.ai --key-id <old-key-id> --json

After rotation the hub holds two rows for your identity in musehub_auth_keys:

json musehub_auth_keys — two rows, same identity_id
[
  {
    "identity_id":    "sha256:41dfaf04…",  // ← never changes
    "fingerprint":    "sha256:a3f2c9d8…",  // old key
    "algorithm":      "ed25519",
    "public_key_b64": "3aB7kLmN…",
    "label":          "macbook-pro",
    "created_at":     "2026-04-01T10:00:00Z"
  },
  {
    "identity_id":    "sha256:41dfaf04…",  // ← same identity_id
    "fingerprint":    "sha256:d8faf800…",  // new key after rotation
    "algorithm":      "ed25519",
    "public_key_b64": "qR5sT6uV…",
    "label":          "macbook-pro (rotated)",
    "created_at":     "2026-05-01T09:00:00Z"
  }
]

What the profile page displays

The profile hero strip always shows the most recently registered key for the identity — the row from musehub_auth_keys with the latest created_at for that identity_id. It does not display the identity_id as the key — the identity_id is a stable account anchor, not a per-request credential.

Profile strip rowSource
Algorithm label (ed25519) musehub_auth_keys.algorithm — most recent key
Public key (3aB7kLmN…) musehub_auth_keys.public_key_b64 — most recent key
Fingerprint (sha256:d8fa…) musehub_auth_keys.fingerprint — most recent key
Registered date musehub_identities.created_at — identity registration date, not key rotation date

If no key row exists for an identity (e.g. the identity was created through a code path that bypassed the standard auth flow), the profile falls back to displaying the identity_id as the fingerprint. This is a degraded state — the correct fix is to run muse auth rotate to register an active key.

The rotation invariant

The identity is the mnemonic, not the key. Keys are disposable credentials derived from the mnemonic. You can rotate as often as you like and hold multiple active keys simultaneously. None of this changes who you are — your identity_id, your repos, your commits, your attestations are all anchored to the mnemonic, not to any individual key.

Agent sub-keys

Every agent in the ecosystem inherits its signing key from the parent's root mnemonic via HD derivation — not a fresh mnemonic. The parent process (human shell or Agentception orchestrator) derives a 64-byte sub-seed at a specific HD path and passes it to the spawned agent over a pipe. The agent's process never sees the root mnemonic.

Key injection — two environment variables

VariableValuePurpose
MUSE_AGENT_KEY_FD integer (file descriptor number) Read end of a pipe carrying 64 raw bytes — the agent's sub-seed
MUSE_AGENT_HANDLE string The agent's registered handle on the hub; paired with the injected key

Identity resolution order

get_signing_identity() in muse.core.auth checks these sources in order, using the first one that is set:

text resolution order
1. MUSE_AGENT_KEY_FD set?
      → read exactly 64 bytes from that fd
      → derive Ed25519 keypair via SLIP-0010
      → handle = MUSE_AGENT_HANDLE

2. MUSE_AGENT_HD_SEED set?
      → treat as hex-encoded 64-byte sub-seed
      → derive Ed25519 keypair via SLIP-0010

3. ~/.muse/identity.toml present?
      → load matching [host] or [host#handle] section

4. None of the above → IdentityNotFound raised

Agentception injection sequence

python orchestrator — deriving and injecting a sub-seed
import os, subprocess
from muse.core.identity import derive_sub_seed

# Derive a domain-scoped sub-seed for agent slot 7
sub_seed = derive_sub_seed(
    root_seed=orchestrator_root_seed,
    domain=678195575,  # Code domain (muse/code)
    entity_type=1,    # Agent
    entity_id=7,      # Unique slot
)  # → 64 bytes; cannot produce Identity or Payment keys

# Open a pipe; write the sub-seed into the write end and close it
read_fd, write_fd = os.pipe()
os.write(write_fd, sub_seed)   # exactly 64 bytes
os.close(write_fd)

# Spawn the agent with the read fd and handle in its environment
proc = subprocess.Popen(
    ["python3", "-m", "my_agent"],
    env={
        **os.environ,
        "MUSE_AGENT_KEY_FD": str(read_fd),
        "MUSE_AGENT_HANDLE": "worker-7",
    },
    pass_fds=(read_fd,),
)
os.close(read_fd)   # orchestrator closes its copy after spawn
python spawned agent — identity resolves automatically
from muse.core.auth import get_signing_identity

# muse commit, muse push, muse sign all call this internally.
# The agent process never needs to call it directly.
identity = get_signing_identity(hub="http://staging.musehub.ai")
# → SigningIdentity(handle="worker-7", algorithm="ed25519",
#                  public_key="ed25519:...")
The sub-seed is domain-scoped by construction. A Code-domain sub-seed (domain=678195575) can only derive Code-domain keys — it physically cannot produce an Identity (domain=1660078172') or Payment (domain=284229149') key. There is no policy rule enforcing this; the HD math simply does not connect those branches.

Identity repos

Every registered identity on MuseHub has a dedicated Muse repository: {handle}/identity with domain="identity". This repo is the canonical source of truth for a principal's public key, quorum threshold, and relationship graph — the PostgreSQL tables are a queryable index rebuilt from it, not the other way around.

Because the identity repo is a normal Muse repo, you get content-addressed history for free: every key rotation, every membership change, and every spawns relationship is a commit with a tamper-evident SHA-256 ID, auditable with muse log.

IdentityRecord — the file stored in the repo

Each identity repo stores one JSON file at identities/{handle}.json (the IdentityRecord) and zero or more relationship files at relationships/{from}--{edge}--{to}.json:

json identities/gabriel.json — IdentityRecord
{
  "handle":        "gabriel",
  "type":          "human",
  "pubkey":        "ed25519:scbtcAeEYMv3cCBNcYJU153gqaT1UpSBVDVttTj_9-Y",
  "quorum":        null,
  "registered_at": "2026-04-21T14:32:07Z",
  "metadata":      { "display_name": "Gabriel" }
}
FieldTypeDescription
handlestrHub username — matches the repo owner slug
type"human" | "agent" | "org"Principal type
pubkeyed25519:<base64url> | nullCurrent signing public key. Null for orgs (authority is quorum, not a single key)
quorumint | nullApproval threshold for org governance. Null for humans and agents
registered_atISO-8601 stringWhen this identity was first registered
metadataobjectOptional display fields: display_name, etc.

Repo layout

text gabriel/identity repo — HEAD snapshot
identities/
  gabriel.json                           ← IdentityRecord

relationships/
  gabriel--spawns--claude-code.json      ← RelationshipRecord
  gabriel--member_of--acme-org.json      ← RelationshipRecord

REST API — reading from the repo

GET /api/identities/{handle} reads pubkey, quorum, identity_type, and display_name from the identity repo HEAD. If the repo is absent (e.g. pre-registration or migration period) the response falls back to the DB values.

bash
curl http://staging.musehub.ai/api/identities/gabriel
json
{
  "handle":        "gabriel",
  "identity_type": "human",
  "pubkey":        "ed25519:scbtcAeEYMv3cCBNcYJU153gqaT1UpSBVDVttTj_9-Y",
  "quorum":        null,
  "display_name":  "Gabriel",
  "fingerprint":   "sha256:220897cdf3a...",
  "created_at":    "2026-04-21T14:32:07Z"
}

Key rotation as a commit

muse auth rotate does two things atomically: it re-registers the new public key with the hub and commits an updated IdentityRecord to the identity repo. This gives a full, tamper-evident rotation history:

bash
muse auth rotate --hub http://staging.musehub.ai --json
# Rotates to the next HD index, updates hub registration,
# and commits a new identities/gabriel.json to gabriel/identity.

# View the full rotation history
muse -C gabriel/identity log --json

The Python API for reading the canonical record directly from the repo:

python muse.plugins.identity.records
from muse.plugins.identity.records import (
    IdentityRecord, RelationshipRecord,
    identity_path, relationship_path,
    record_to_bytes,
)

# Build a record
record: IdentityRecord = {
    "handle":        "gabriel",
    "type":          "human",
    "pubkey":        "ed25519:...",
    "quorum":        None,
    "registered_at": "2026-04-21T14:32:07Z",
    "metadata":      {"display_name": "Gabriel"},
}

# File paths in the identity repo
identity_path("gabriel")                          # → "identities/gabriel.json"
relationship_path("gabriel", "spawns", "claude-code") # → "relationships/gabriel--spawns--claude-code.json"

Key recovery

If ~/.muse/identity.toml is lost or you move to a new machine, muse auth recover rebuilds your identity from the 24-word BIP-39 mnemonic stored in your password manager. Every derived key is deterministic — the same mnemonic always produces the same key tree.

bash muse auth recover — interactive transcript
$ muse auth recover --hub http://staging.musehub.ai

Enter your 24-word BIP-39 mnemonic (words separated by spaces):
> abandon ability able about above absent absorb abstract absurd abuse
  access accident account accuse achieve acid acoustic acquire across
  act action actor actress actual

✔  Mnemonic valid (24 words, checksum OK)

Deriving keys from mnemonic...
  domain=1660078172' (Identity), entity_type=0' (Human), entity_id=0', index=0'
  hd_path:    m/1075233755'/1660078172'/0'/0'/0'/0'
  public_key: ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM
  fingerprint: a3f2c9d8e1b4...

Checking hub registration...
  ✔  Key registered — handle: gabriel

Restored ~/.muse/identity.toml
  [staging.musehub.ai]
  handle      = "gabriel"
  fingerprint = "a3f2c9d8e1b4..."
  hd_path     = "m/1075233755'/1660078172'/0'/0'/0'/0'"
Back up the mnemonic immediately after muse auth keygen — pipe it from the keychain directly to your password manager so it never appears in terminal scrollback: security find-generic-password -s muse -a mnemonic -w | pbcopy (macOS) or secret-tool lookup service muse account mnemonic | xclip -selection clipboard (Linux). Losing the mnemonic means permanent loss of all derived keys.

Migration — pre-Phase-1 keys

Muse Phase 1 replaced sequential domain integers (0–6) with hash-derived values computed as int.from_bytes(sha256(name.encode())[:4], "big") & 0x7FFFFFFF. Users who generated their identity before Phase 1 have keys derived at the old paths. muse migrate domain-integers detects those entries in ~/.muse/identity.toml, re-derives the correct keys at the new paths, and re-registers each fingerprint with its hub.

bash migrate legacy keys
# Inspect — see what would change (no writes)
muse migrate domain-integers --dry-run --json

# Apply — re-derive keys and re-register with each hub
muse migrate domain-integers --json

# Re-derive keys and update identity.toml, but skip hub re-registration
muse migrate domain-integers --no-register --json
json --dry-run output
{
  "dry_run":          true,
  "entries_found":    1,
  "entries_migrated": 0,
  "results": [
    {
      "hub_key":         "staging.musehub.ai",
      "old_hd_path":    "m/1075233755'/0'/0'/0'/0'/0'",
      "new_hd_path":    "m/1075233755'/1660078172'/0'/0'/0'/0'",
      "old_fingerprint": "sha256:a3f2c9d8...",
      "new_fingerprint": "sha256:b7e1a4c2...",
      "hub_registered":  false
    }
  ]
}
Migration is irreversible in the sense that the hub now expects the new fingerprint. Always run --dry-run first to inspect what will change. The mnemonic in the keychain is unchanged — only the derived HD path and hub registration are updated. If hub re-registration fails, the key is still re-derived locally; run the command again once the hub is reachable.

MPay — Micropayments

MPay is the native micropayment layer built directly on the same Ed25519 identity infrastructure as MSign. There are no payment processors, no API keys, no OAuth scopes — a payment is just a canonical message signed by the sender's key. Units are nanoMUSE (10−9 MUSE).

Canonical message

The message signed by the sender is UTF-8 encoded, newline-separated:

text MPay canonical message format
MPAY
{sender_handle}
{recipient_handle}
{amount_nano}
{nonce_hex}

nonce_hex is a random hex string generated by the sender for each payment. The server enforces a unique constraint on nonces — submitting the same nonce twice returns the existing claim (idempotent).

Full sign → submit → verify worked example

Step 1 — sign the payment. muse sign payment prints the canonical message, the Ed25519 signature, and the complete MPayClaimRequest payload ready to POST:

bash sign a 1 µMUSE payment
muse sign payment \
  --from gabriel \
  --to alice \
  --amount 1000000 \
  --nonce a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 \
  --json
json output
{
  "canonical_message": "MPAY\ngabriel\nalice\n1000000\na3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
  "signature":         "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
  "sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
  "claim_request": {
    "sender":            "gabriel",
    "recipient":         "alice",
    "amount_nano":       1000000,
    "nonce_hex":         "a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
    "signature":         "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
    "sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
    "memo":              null
  }
}

Step 2 — submit the claim. Pipe the claim_request object to POST /mpay/claim using muse sign request to attach the MSign Authorization header:

bash submit claim to the hub
# Write the claim_request object to a file
muse sign payment --from gabriel --to alice --amount 1000000 \
  --nonce a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 --json \
  | python3 -c "import sys,json; json.dump(json.load(sys.stdin)['claim_request'], open('/tmp/claim.json','w'))"

# Sign and POST
muse sign request \
  --method POST \
  --url http://staging.musehub.ai/mpay/claim \
  --body-file /tmp/claim.json \
  --json
json MPayClaimResponse
{
  "claim_id":          "sha256:7f4a2b8cd3e9f1a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
  "sender":            "gabriel",
  "recipient":         "alice",
  "amount_nano":       1000000,
  "nonce_hex":         "a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
  "signature":         "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
  "sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
  "memo":              null,
  "created_at":        "2026-04-21T15:00:00Z",
  "confirmed_at":      null,
  "voided_at":         null
}

Step 3 — query the ledger to verify settlement:

bash query sender ledger
muse sign request \
  --method GET \
  --url http://staging.musehub.ai/mpay/ledger/gabriel \
  --json

MPayClaimRequest fields

FieldTypeDescription
senderstrSending identity handle
recipientstrReceiving identity handle
amount_nanointAmount in nanoMUSE (10−9 MUSE)
nonce_hexstrExactly 64-char hex string (32 random bytes); server enforces uniqueness per sender
signaturestred25519:<base64url> — signature over the canonical message
sender_public_keystred25519:<base64url> — sender's public key for verification
memostr | nullOptional human-readable note attached to the payment

MPayClaimResponse fields

FieldTypeDescription
claim_idstrsha256:<64-hex> content-addressed claim ID
senderstrSending identity handle
recipientstrReceiving identity handle
amount_nanointPayment amount in nanoMUSE
nonce_hexstr64-char hex nonce from the original request
signaturestred25519:<base64url> — sender's signature
sender_public_keystred25519:<base64url> — sender's public key
memostr | nullOptional payment memo
created_atstrUTC timestamp of claim creation
confirmed_atstr | nullUTC timestamp of settlement; null if pending
voided_atstr | nullUTC timestamp if claim was voided; null otherwise

Ledger query

The ledger endpoint returns all sent and received claims for an identity, with running totals:

json GET /mpay/ledger/gabriel — response
{
  "handle":              "gabriel",
  "sent": [
    {
      "claim_id":          "sha256:7f4a2b8cd3e9f1a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
      "sender":            "gabriel",
      "recipient":         "alice",
      "amount_nano":       1000000,
      "nonce_hex":         "a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
      "signature":         "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
      "sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
      "memo":              null,
      "created_at":        "2026-04-21T15:00:00Z",
      "confirmed_at":      "2026-04-21T15:00:03Z",
      "voided_at":         null
    }
  ],
  "received":            [],
  "total_sent_nano":     1000000,
  "total_received_nano": 0
}
Verification rejects the claim if any of the four canonical fields (sender, recipient, amount_nano, nonce_hex) do not match the signature. The server also checks that sender_public_key is registered to the sender handle in MusehubAuthKey before accepting.