feat: wire IdentityPlugin to MuseHub — identity repos as canonical truth
Overview
The IdentityPlugin (muse/plugins/identity/plugin.py) is fully implemented and registered — it tracks identities/<handle>.json and relationships/<from>--<edge>--<to>.json with three-way merge, acyclicity enforcement (I1), OT ops, and Harmony conflict fingerprinting.
MuseHub's identity system (MusehubIdentity + MusehubAuthKey in PostgreSQL) is also complete.
The two layers don't talk to each other. The PostgreSQL tables are the only source of truth. They should become a cache/index; identity repos should be the canonical truth.
Current state
muse auth register → INSERT INTO musehub_identities ...
INSERT INTO musehub_auth_keys ...
(nothing written to any Muse repo)
key rotation → UPDATE musehub_auth_keys ...
(no commit, no audit trail)
agentception spawn → MUSE_AGENT_KEY_FD + MUSE_AGENT_HANDLE
(no SPAWNS relationship recorded anywhere)
orgs → IdentityRecord.type = 'org' exists in schema
RelationshipRecord.edge_type = 'member_of' exists in schema
(no creation flow, no hub routes, no repos)
Target state
Each registered identity gets a MuseHub repo with domain="identity". The repo is the canonical record; the DB is an index rebuilt from it.
gabriel/identity ← repo, domain=identity
identities/
gabriel.json ← IdentityRecord
relationships/
gabriel--spawns--agent-42.json ← RelationshipRecord
alice--member_of--acme-org.json ← RelationshipRecord
Work items
1. Create identity repo on registration
In musehub/services/musehub_auth.py (or wherever register_key lands), after inserting the identity row, create the identity repo and commit the initial identity record:
# After INSERT INTO musehub_identities:
from musehub.services.musehub_repository import create_repo, commit_files
from muse.plugins.identity.records import IdentityRecord, record_to_bytes, identity_path
record: IdentityRecord = {
"handle": handle,
"type": "human",
"pubkey": public_key_b64,
"quorum": None,
"registered_at": now.isoformat(),
"metadata": {},
}
repo = await create_repo(session, owner_identity_id=identity_id,
slug="identity", domain="identity", visibility="private")
await commit_files(session, repo.repo_id, "main", {
identity_path(handle): record_to_bytes(record),
}, message=f"identity: register {handle}", author=identity_id)
2. Key rotation as a commit
Currently muse auth rotate updates the DB row. It should also commit to the identity repo:
# In the rotate handler — after updating musehub_auth_keys:
old_record = await read_identity_record(session, repo_id, handle)
new_record = {**old_record, "pubkey": new_public_key_b64}
await commit_files(session, repo_id, "main", {
identity_path(handle): record_to_bytes(new_record),
}, message=f"identity: rotate key for {handle}", author=identity_id)
This gives a full key-rotation history at muse log on the identity repo — tamper-evident, content-addressed.
3. Agent SPAWNS relationship
When agentception registers a sub-agent (or when muse agent register is called with a parent), commit the relationship to the parent's identity repo:
from muse.plugins.identity.records import RelationshipRecord, record_to_bytes, relationship_path
import datetime
rel: RelationshipRecord = {
"from_handle": parent_handle,
"to_handle": agent_handle,
"edge_type": "spawns",
"weight": None,
"authorized_by": [signed_authorization],
}
await commit_files(session, parent_identity_repo_id, "main", {
relationship_path(parent_handle, "spawns", agent_handle): record_to_bytes(rel),
}, message=f"identity: {parent_handle} spawns {agent_handle}", author=parent_identity_id)
This makes the full agent genealogy tree queryable via muse log and muse diff on the identity repo — not just a DB query.
4. Org creation and member_of relationships
Add hub routes:
POST /orgs — create org identity + repo
POST /orgs/{org}/members/{handle} — add member (commits member_of edge, requires org admin sig)
DELETE /orgs/{org}/members/{handle}— remove member
GET /orgs/{org}/members — read from identity repo HEAD
Org identity record:
org_record: IdentityRecord = {
"handle": org_slug,
"type": "org",
"pubkey": None, # org key is its quorum threshold
"quorum": threshold, # N-of-M
"registered_at": now.isoformat(),
"metadata": {"display_name": display_name},
}
Membership edge (requires quorum approval — links back to the governance system from issue #3):
member_rel: RelationshipRecord = {
"from_handle": member_handle,
"to_handle": org_slug,
"edge_type": "member_of",
"weight": "admin" | "write" | "read",
"authorized_by": [sig_from_each_quorum_member],
}
5. Quorum resolution via identity handles (not raw fingerprints)
Today governance.json lists raw Ed25519 fingerprints:
{"quorum": {"threshold": 2, "members": ["sha256:aaa...", "sha256:bbb..."]}}
After this issue, it should list handles:
{"quorum": {"threshold": 2, "members": ["gabriel", "alice"]}}
check_quorum in musehub_governance.py resolves handles → fingerprints by reading the identity repo HEAD at merge time. Key rotation propagates automatically — no governance.json update needed.
# In check_quorum — replace fingerprint set lookup with handle lookup:
for member_handle in members:
fp = await get_current_fingerprint_from_identity_repo(session, member_handle)
member_fps.add(fp)
6. Hub API reads from identity repo HEAD
GET /users/{handle} currently reads MusehubIdentity directly. After this issue:
- Read
identities/{handle}.jsonfrom the identity repo HEAD (canonical truth) - Fall back to DB if identity repo doesn't exist yet (migration period)
- Background job syncs DB from repo HEAD on each push (keeps search/index fast)
Migration
Existing registered users have no identity repo. A one-time migration job should:
- For each row in
musehub_identities: create the identity repo + initial commit - For each row in
musehub_auth_keys: no extra work — already reflected in the identity record'spubkeyfield via the initial commit - For known agent relationships in
musehub_identities.agent_capabilities: seed SPAWNS edges where parent is known
Acceptance criteria
muse auth registercreates{handle}/identityrepo withdomain="identity"muse auth rotatecommits a newidentities/{handle}.jsonto the identity repomuse agent register --parent gabrielcommits a SPAWNS relationship to gabriel's identity repoPOST /orgscreates an org identity record and repoPOST /orgs/{org}/members/{handle}commits amember_ofedge (quorum-gated)check_quorumaccepts handles ingovernance.jsonand resolves to fingerprints via identity repo HEADGET /users/{handle}reads from identity repo HEAD with DB fallback- Migration job backfills identity repos for all existing registered users
muse logon an identity repo shows the full key-rotation and relationship history
Phase 2 complete — key rotation commits to the identity repo
Commit: sha256:7dbf2bbdb258bae615b0b7c84872ffd2f0c5b6c5387dbb79e4933c60879444a5
What landed
_commit_key_rotation_to_identity_repo()— reads the current HEADIdentityRecord, replacespubkeywith the new rotated key, and commits the updated record tomain. No-op when the identity repo doesn't exist (migration-period safety).- Wired into
add_key_for_identity()immediately after the key DB row commits — every rotation now produces a tamper-evident audit entry in the identity repo's commit history.
Tests — 7/7 GREEN
tests/test_identity_repo_phase2.py
| Class | Tests |
|---|---|
TestRotationCreatesCommit |
second commit after rotation, third after second rotation, message contains handle |
TestRotationUpdatesPubkey |
new pubkey in HEAD, original commit immutable, handle/type preserved, second rotation reflects latest key |
Acceptance criteria
muse auth registercreates{handle}/identityrepo withdomain_id='identity'muse auth rotatecommits a newidentities/{handle}.jsonto the identity repomuse agent register --parent gabrielcommits a SPAWNS relationship ← Phase 3- Org creation and
member_ofedges ← Phase 4 - Quorum resolution via handles ← Phase 5
GET /users/{handle}reads from identity repo HEAD ← Phase 6- Migration backfill ← Phase 6
Phase 3 complete — agent SPAWNS relationship committed to parent's identity repo
Commit: sha256:60dcd3c10402220de30b216efe9310dfd8e0e3d3be0066541f7794416dd9902e
What landed
_commit_spawns_relationship()— looks up the parent's identity repo by handle and commits aRelationshipRecordatrelationships/{parent}--spawns--{agent}.json. No-op when the parent has no identity repo (migration-period safety).- Wired into
register_agent_identity()immediately after the agent's own identity repo is created — every spawn now produces a tamper-evident SPAWNS edge in the parent's commit history.
Tests — 8/8 GREEN (27/27 cumulative)
tests/test_identity_repo_phase3.py
| Class | Tests |
|---|---|
TestSpawnsRelationshipCreated |
commit added to parent repo, relationship file at correct path, two agents = two commits, agent's own repo untouched |
TestRelationshipRecordContent |
from_handle, to_handle, edge_type=spawns |
TestSpawnsNoOpWhenParentMissing |
ghost parent doesn't raise, agent repo still created |
Acceptance criteria
muse auth registercreates{handle}/identityrepo withdomain_id='identity'muse auth rotatecommits a newidentities/{handle}.jsonto the identity repomuse agent register --parent gabrielcommits a SPAWNS relationship to gabriel's identity repo- Org creation and
member_ofedges ← Phase 4 - Quorum resolution via handles ← Phase 5
GET /users/{handle}reads from identity repo HEAD ← Phase 6- Migration backfill ← Phase 6
Phase 4 complete — org creation, member_of relationships, hub routes
Commit: sha256:cd0c292b1271899d829703d50397e8cebb07896859bcee7a1ecd43162b00e59f
What landed
commit_files_to_repo(delete_paths=)— extended with file deletion support: reads HEAD manifest, removes named paths, merges with new files before committing.musehub/services/musehub_orgs.py— new service module:create_org()— creates orgMusehubIdentity+ identity repo (type='org',pubkey=None,quorum=N)add_org_member()— commitsmember_ofRelationshipRecordto org's identity reporemove_org_member()— commits deletion of membership filelist_org_members()— readsmember_ofedges from identity repo HEAD
musehub/api/routes/api/orgs.py— four new routes:
| Method | Path | Response |
|---|---|---|
| POST | /api/orgs | 201 |
| POST | /api/orgs/{org}/members/{handle} | 201 |
| GET | /api/orgs/{org}/members | 200 |
| DELETE | /api/orgs/{org}/members/{handle} | 204 |
Tests — 20/20 GREEN (47/47 cumulative)
tests/test_identity_repo_phase4.py
| Class | Tests |
|---|---|
TestOrgCreation |
201, handle, type=org, identity repo created, record type/pubkey/quorum, 409 duplicate, 401 unauthed |
TestAddOrgMember |
201, relationship committed, record content, 404 unknown org, 401 unauthed |
TestListOrgMembers |
empty list, populated after add, 404 unknown org |
TestRemoveOrgMember |
204, absent from list, absent from repo, 404 nonexistent member, 401 unauthed |
Acceptance criteria
muse auth registercreates{handle}/identityrepomuse auth rotatecommits updatedidentities/{handle}.jsonmuse agent register --parentcommits SPAWNS relationship to parent repoPOST /orgscreates org identity record and repoPOST /orgs/{org}/members/{handle}commitsmember_ofedgeGET /orgs/{org}/membersreads from identity repo HEADDELETE /orgs/{org}/members/{handle}removes member via commit- Quorum resolution via handles ← Phase 5
GET /users/{handle}reads from identity repo HEAD ← Phase 6- Migration backfill ← Phase 6
Phase 5 complete ✅ — Quorum resolution via identity handles
What was built
resolve_handle_to_fingerprint(session, handle) (new, musehub_governance.py):
- Reads the handle's identity repo HEAD IdentityRecord
pubkeyfield - Decodes
ed25519:<base64url>→ raw bytes viadecode_pubkey() - Returns canonical
sha256:<hex>fingerprint, orNonewhen no identity repo / no pubkey
check_quorum() updated:
sha256:...entries inmembers→ raw fingerprint match (backward compat, existing tests unchanged)- Plain handle entries → resolved to fingerprint via identity repo HEAD at merge time
- Key rotation propagates automatically — no governance.json rewrite needed
Tests (8 new, all GREEN)
| Test | Scenario |
|---|---|
| test_handle_member_resolves_to_fingerprint | resolve_handle_to_fingerprint reads identity repo HEAD pubkey |
| test_handle_with_no_identity_repo_returns_none | returns None when no identity repo exists |
| test_check_quorum_handle_member_counts_when_key_matches | handle resolved, reviewer key matches → quorum met |
| test_check_quorum_handle_member_not_counted_when_key_differs | identity repo pubkey ≠ reviewer key → not counted |
| test_check_quorum_handle_with_no_identity_repo_not_counted | no identity repo → not counted |
| test_check_quorum_fp_member_still_works | sha256:... fingerprint entries still work |
| test_key_rotation_propagates_via_identity_repo | after rotation, new key resolves via identity repo HEAD |
| test_check_quorum_mixed_members_both_resolve | mixed handle + fingerprint member list, 2-of-2 |
Cumulative
- Phase 1: 12 tests — identity repo created on registration
- Phase 2: 7 tests — key rotation commits to identity repo
- Phase 3: 8 tests — agent SPAWNS relationship committed to parent's identity repo
- Phase 4: 20 tests — org creation, member_of routes
- Phase 5: 8 tests — quorum resolves handles via identity repo HEAD
- Total: 55 tests GREEN
Phase 5 complete — Quorum resolution via identity handles
What was built
resolve_handle_to_fingerprint(session, handle) — new in musehub_governance.py:
- Reads the handle's identity repo HEAD IdentityRecord pubkey field
- Decodes ed25519:<base64url> to raw bytes via decode_pubkey()
- Returns canonical sha256:<hex> fingerprint, or None when no identity repo / no pubkey
check_quorum() updated:
- sha256:... entries in members — raw fingerprint match (backward compat, existing tests unchanged)
- Plain handle entries — resolved to fingerprint via identity repo HEAD at merge time
- Key rotation propagates automatically — no governance.json rewrite needed
Tests (8 new, all GREEN)
- test_handle_member_resolves_to_fingerprint
- test_handle_with_no_identity_repo_returns_none
- test_check_quorum_handle_member_counts_when_key_matches
- test_check_quorum_handle_member_not_counted_when_key_differs
- test_check_quorum_handle_with_no_identity_repo_not_counted
- test_check_quorum_fp_member_still_works (backward compat)
- test_key_rotation_propagates_via_identity_repo
- test_check_quorum_mixed_members_both_resolve (2-of-2, handle+fingerprint)
Cumulative: 55 tests GREEN
Phase 1: 12 — identity repo created on registration Phase 2: 7 — key rotation commits to identity repo Phase 3: 8 — agent SPAWNS relationship committed to parent identity repo Phase 4: 20 — org creation, member_of routes Phase 5: 8 — quorum resolves handles via identity repo HEAD
Phase 6 complete — GET /api/identities/{handle} reads from identity repo HEAD
What was built
_read_identity_record_from_repo(session, handle) — new private helper in identities.py:
- Reads identity repo HEAD manifest, locates identities/{handle}.json, parses and returns the IdentityRecord dict
- Returns None when no identity repo exists (migration-period DB fallback)
_row_to_dict(row, repo_record=None) — updated:
- pubkey: sourced from identity repo (canonical truth); null when no repo
- quorum: sourced from identity repo; null when no repo
- identity_type: sourced from identity repo type field
- display_name: repo metadata.display_name takes precedence over DB value
- bio, avatar_url, and all other profile fields: still from DB (enrichment layer)
Tests (11 new, all GREEN)
- test_get_identity_returns_pubkey_from_repo
- test_get_identity_pubkey_reflects_key_rotation (immediately, no DB update needed)
- test_get_identity_type_from_repo
- test_get_org_identity_includes_quorum
- test_get_identity_display_name_from_repo_metadata
- test_db_bio_present_alongside_repo_pubkey
- test_db_avatar_url_present
- test_no_identity_repo_falls_back_to_db
- test_no_identity_repo_pubkey_is_null
- test_no_identity_repo_quorum_is_null
- test_unknown_handle_returns_404
Cumulative: 66 tests GREEN
Phase 1: 12 — identity repo created on registration Phase 2: 7 — key rotation commits to identity repo Phase 3: 8 — agent SPAWNS relationship committed to parent identity repo Phase 4: 20 — org creation, member_of routes Phase 5: 8 — quorum resolves handles via identity repo HEAD Phase 6: 11 — GET /api/identities/{handle} reads from identity repo HEAD
All phases of issue #19 (staging #2) complete.
Migration backfill complete — identity repos for pre-Phase-1 identities
What was built
musehub/maintenance/identity_backfill.py — backfill_identity_repos(session):
- Scans all non-deleted MusehubIdentity rows
- Skips identities that already have an identity repo (idempotent)
- Human/agent: pubkey resolved from most-recently-created MusehubAuthKey
- Org: pubkey=None, quorum from org_quorum column, type='org'
- No key registered: pubkey=None (graceful)
- Returns {total, created, skipped} summary dict
- Can be run from a management shell or scheduled maintenance job
Tests (9 new, all GREEN)
- test_backfill_creates_identity_repo_for_human
- test_backfill_human_record_has_correct_pubkey
- test_backfill_agent_record_type_is_agent
- test_backfill_org_record_has_null_pubkey_and_quorum
- test_backfill_no_key_uses_null_pubkey
- test_backfill_idempotent_no_duplicate_repo
- test_backfill_idempotent_single_commit
- test_backfill_skips_deleted_identities
- test_backfill_returns_counts
Cumulative: 76 tests GREEN
All work items from issue #19 (staging #2) are now complete, including migration.
Issue #19 (feat: wire IdentityPlugin to MuseHub) complete.
Phases delivered:
- Phase 1–4: identity repo creation at registration, IdentityRecord committed
- Phase 5: check_quorum() resolves handle-based members via identity repo HEAD pubkey; sha256: entries still accepted for backward compat
- Phase 6: GET /api/identities/{handle} reads pubkey, quorum, identity_type, display_name from identity repo HEAD (DB fallback when repo absent)
- Migration backfill: ran successfully on localhost and staging (3 repos created: gabriel, aaronrene, claude-code); backfill code removed
Adapter layer bonus: canonical read_object_bytes(obj) in musehub/storage/backends.py eliminates all manual s3/local dispatch patterns across governance, identities, orgs, and auth — the class of bug that triggered this was the key motivation.
Staging verified: GET /api/identities/gabriel → pubkey: ed25519:scbtcAeEYMv3cCBNcYJU153gqaT1UpSBVDVttTj_9-Y ✅
Issue #19 (feat: wire IdentityPlugin to MuseHub) complete.
Phases delivered:
- Phase 1–4: identity repo creation at registration, IdentityRecord committed
- Phase 5: check_quorum() resolves handle-based members via identity repo HEAD pubkey; sha256: entries still accepted for backward compat
- Phase 6: GET /api/identities/{handle} reads pubkey, quorum, identity_type, display_name from identity repo HEAD (DB fallback when repo absent)
- Migration backfill: ran successfully on localhost and staging (3 repos created: gabriel, aaronrene, claude-code); backfill code removed
Adapter layer bonus: canonical read_object_bytes(obj) in musehub/storage/backends.py eliminates all manual s3/local dispatch patterns across governance, identities, orgs, and auth.
Staging verified: GET /api/identities/gabriel → pubkey: ed25519:scbtcAeEYMv3cCBNcYJU153gqaT1UpSBVDVttTj_9-Y
Phase 1 complete — identity repo created on registration
Commit:
sha256:54fa5360cee975c50d7d7730f7cf88bc0a7c5ffc6f39ca8d0c744a59be179f42What landed
commit_files_to_repo()— new server-side helper inmusehub_sync.pythat writes a set of files to a repo as a single commit without requiring a client push. Used by identity bootstrapping; available for any future server-side write._create_identity_repo()— idempotent helper inmusehub_auth.pythat creates{handle}/identity(domain_id='identity',visibility='private') and commits the initialIdentityRecordtomain.verify_and_authenticate()(human registrations) andregister_agent_identity()(agent registrations).Tests — 12/12 GREEN
tests/test_identity_repo_phase1.pyTestIdentityRepoCreatedOnRegistrationTestIdentityRepoInitialCommitTestIdentityRecordContentTestAgentIdentityRepoAcceptance criteria
muse auth registercreates{handle}/identityrepo withdomain_id='identity'muse auth rotatecommits a newidentities/{handle}.jsonto the identity repo ← Phase 2muse agent register --parent gabrielcommits a SPAWNS relationship ← Phase 3member_ofedges ← Phase 4GET /users/{handle}reads from identity repo HEAD ← Phase 6