gabriel / musehub public
Closed #2 Enhancement
filed by gabriel human · 49 days ago

feat: wire IdentityPlugin to MuseHub — identity repos as canonical truth

10 Anchors
153 Blast radius
5 Churn 30d
0 Proposals

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:

  1. Read identities/{handle}.json from the identity repo HEAD (canonical truth)
  2. Fall back to DB if identity repo doesn't exist yet (migration period)
  3. 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:

  1. For each row in musehub_identities: create the identity repo + initial commit
  2. For each row in musehub_auth_keys: no extra work — already reflected in the identity record's pubkey field via the initial commit
  3. For known agent relationships in musehub_identities.agent_capabilities: seed SPAWNS edges where parent is known

Acceptance criteria

  • muse auth register creates {handle}/identity repo with domain="identity"
  • muse auth rotate commits a new identities/{handle}.json to the identity repo
  • muse agent register --parent gabriel commits a SPAWNS relationship to gabriel's identity repo
  • POST /orgs creates an org identity record and repo
  • POST /orgs/{org}/members/{handle} commits a member_of edge (quorum-gated)
  • check_quorum accepts handles in governance.json and resolves to fingerprints via identity repo HEAD
  • GET /users/{handle} reads from identity repo HEAD with DB fallback
  • Migration job backfills identity repos for all existing registered users
  • muse log on an identity repo shows the full key-rotation and relationship history
Activity10
gabriel opened this issue 49 days ago
gabriel 49 days ago

Phase 1 complete — identity repo created on registration

Commit: sha256:54fa5360cee975c50d7d7730f7cf88bc0a7c5ffc6f39ca8d0c744a59be179f42

What landed

  • commit_files_to_repo() — new server-side helper in musehub_sync.py that 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 in musehub_auth.py that creates {handle}/identity (domain_id='identity', visibility='private') and commits the initial IdentityRecord to main.
  • Wired into verify_and_authenticate() (human registrations) and register_agent_identity() (agent registrations).

Tests — 12/12 GREEN

tests/test_identity_repo_phase1.py

Class Tests
TestIdentityRepoCreatedOnRegistration repo created, domain_id=identity, visibility=private, no duplicate on second login
TestIdentityRepoInitialCommit main branch exists, head_commit_id set, manifest contains identities/{handle}.json
TestIdentityRecordContent handle, type=human, pubkey, registered_at all correct
TestAgentIdentityRepo agent registration creates repo, type=agent

Acceptance criteria

  • muse auth register creates {handle}/identity repo with domain_id='identity'
  • muse auth rotate commits a new identities/{handle}.json to the identity repo ← Phase 2
  • muse agent register --parent gabriel commits a SPAWNS relationship ← Phase 3
  • Org creation and member_of edges ← Phase 4
  • Quorum resolution via handles ← Phase 5
  • GET /users/{handle} reads from identity repo HEAD ← Phase 6
  • Migration backfill ← Phase 6
gabriel 49 days ago

Phase 2 complete — key rotation commits to the identity repo

Commit: sha256:7dbf2bbdb258bae615b0b7c84872ffd2f0c5b6c5387dbb79e4933c60879444a5

What landed

  • _commit_key_rotation_to_identity_repo() — reads the current HEAD IdentityRecord, replaces pubkey with the new rotated key, and commits the updated record to main. 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 register creates {handle}/identity repo with domain_id='identity'
  • muse auth rotate commits a new identities/{handle}.json to the identity repo
  • muse agent register --parent gabriel commits a SPAWNS relationship ← Phase 3
  • Org creation and member_of edges ← Phase 4
  • Quorum resolution via handles ← Phase 5
  • GET /users/{handle} reads from identity repo HEAD ← Phase 6
  • Migration backfill ← Phase 6
gabriel 49 days ago

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 a RelationshipRecord at relationships/{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 register creates {handle}/identity repo with domain_id='identity'
  • muse auth rotate commits a new identities/{handle}.json to the identity repo
  • muse agent register --parent gabriel commits a SPAWNS relationship to gabriel's identity repo
  • Org creation and member_of edges ← Phase 4
  • Quorum resolution via handles ← Phase 5
  • GET /users/{handle} reads from identity repo HEAD ← Phase 6
  • Migration backfill ← Phase 6
gabriel 49 days ago

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 org MusehubIdentity + identity repo (type='org', pubkey=None, quorum=N)
    • add_org_member() — commits member_of RelationshipRecord to org's identity repo
    • remove_org_member() — commits deletion of membership file
    • list_org_members() — reads member_of edges 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 register creates {handle}/identity repo
  • muse auth rotate commits updated identities/{handle}.json
  • muse agent register --parent commits SPAWNS relationship to parent repo
  • POST /orgs creates org identity record and repo
  • POST /orgs/{org}/members/{handle} commits member_of edge
  • GET /orgs/{org}/members reads from identity repo HEAD
  • DELETE /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
gabriel 49 days ago

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 pubkey field
  • Decodes ed25519:<base64url> → 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 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
gabriel 49 days ago

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

gabriel 49 days ago

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.

gabriel 49 days ago

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.

gabriel 49 days ago

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 ✅

gabriel 49 days ago

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

Intelligence
0 direct dependents · 25 transitive
gravity: 0.02% of codebase
top callers: _repo.scss file_last_commits.py repo-page.ts musehub_wire_push.py musehub_governance.py +20
1 modification · 1 author · last touched Jun 3, 2026 gabriel