Orgs: first-class collective identities — CLI + auth wiring
Orgs — First-Class Collective Identities
Background
Humans, agents, and orgs are the three entity types in the Muse identity model.
They sit at entity_type' = 0', 1', 2' in the HD derivation path
m/purpose'/domain'/entity_type'/entity_id'/role'/index'. The docs define orgs as:
A collective identity. Membership and governance live above the key layer; the tree records only that this principal is a collective.
Orgs have no signing key of their own. Their authority is the quorum of their
members — distributed threshold authorisation rather than a single key that can be
stolen. The quorum field sets the threshold; members carry fractional weight
values so voting power can be non-uniform.
The server-side foundation already exists: MusehubIdentity with
identity_type="org" and org_quorum, a full musehub_orgs service
(create_org, add_org_member, remove_org_member, list_org_members), the
/api/orgs route tree, and the full musehub/graph/ stack
(IdentityPushValidator, QuorumEngine, DAG invariants I1–I3). What's missing is
the read endpoint, MSign auth on the existing write routes, and the entire CLI layer
that ties everything together.
Goal
muse hub org create, muse hub org read, and muse hub org member {add,remove,list}
all work end-to-end on localhost and staging, are covered by integration tests, and
the identity DAG is correctly populated (identity repo with pubkey=null,
type="org", quorum=N, plus RelationshipRecord files for each member_of edge).
Identity model recap
HD path
m / 1075233755' / 1660078172' / 2' / entity_id' / role' / index'
^^^
entity_type = org
Orgs have no signing key at any HD path — pubkey is null in their
IdentityRecord. Their authority comes from quorum of members, not from a private
key. The identity_id is blob_id(b"org\x00{handle}\x00{created_at_iso}").
Identity repo layout (org)
identities/{handle}.json ← IdentityRecord {handle, type="org", pubkey=null,
quorum=N, registered_at, metadata}
relationships/{member}/member_of/{org}.json
← RelationshipRecord {from_handle=member,
to_handle=org,
edge_type="member_of",
weight="admin"|"write"|"read",
authorized_by=[...]}
Quorum bootstrap sequence (quorum=2 example)
Step 1: gabriel joins (0 prior members)
→ bootstrap: gabriel self-signs ✓ 1 sig (self)
Step 2: alice joins (1 prior member: gabriel)
→ min(quorum=2, prior=1) = 1 ✓ 1 sig from {gabriel}
Step 3: bob joins (2 prior members: gabriel, alice)
→ min(quorum=2, prior=2) = 2 ✓ 2 sigs from existing members
Step 4: carol tries to join (3 prior members)
→ min(quorum=2, prior=3) = 2 ✓ any 2 of 3 existing members
DAG invariants enforced at push time
| ID | Name | Enforcement | Effect |
|---|---|---|---|
| I1 | Acyclicity | hard error | Push rejected |
| I2 | Root distance | warning | Push accepted, orphan flagged |
| I3 | Authorization | hard error | Push rejected, missing sig listed |
What already exists
| Component | File | Status |
|---|---|---|
DB model: identity_type="org", org_quorum |
musehub/db/musehub_identity_models.py:58,99 |
✅ |
create_org service |
musehub/services/musehub_orgs.py |
✅ |
add_org_member service |
musehub/services/musehub_orgs.py |
✅ |
remove_org_member service |
musehub/services/musehub_orgs.py |
✅ |
list_org_members service |
musehub/services/musehub_orgs.py |
✅ |
POST /api/orgs |
musehub/api/routes/api/orgs.py |
✅ (auth gap — see Phase 1) |
POST /api/orgs/{org}/members/{handle} |
musehub/api/routes/api/orgs.py |
✅ (auth gap) |
GET /api/orgs/{org}/members |
musehub/api/routes/api/orgs.py |
✅ (auth gap) |
DELETE /api/orgs/{org}/members/{handle} |
musehub/api/routes/api/orgs.py |
✅ (auth gap) |
GET /api/orgs/{org} |
— | ❌ missing |
IdentityPushValidator, QuorumEngine, DAG |
musehub/graph/ |
✅ |
IdentityRecord / RelationshipRecord in identity plugin |
muse/plugins/identity/records.py |
✅ |
| Profile page renders orgs | musehub/api/routes/musehub/ui_user_profile.py |
✅ |
muse hub org CLI |
— | ❌ missing entirely |
Phases
Phase 1 — Server: MSign auth + GET /api/orgs/{org}
The existing org routes use require_valid_token (session/JWT auth). The CLI uses
MSign (require_signed_request). Without this fix, every CLI org command returns
401. This phase makes the routes accept MSign and adds the missing read endpoint.
Deliverables
OG_01—GET /api/orgs/{org}returns 200 with full org metadata for existing org; 404 for unknown handleOG_02—GET /api/orgs/{org}returns 403 for non-public org when caller is not a member (if org is private — skip if orgs are always public)OG_03—POST /api/orgsaccepts MSign; rejects unsigned request with 401OG_04—POST /api/orgs/{org}/members/{handle}accepts MSign; 401 if unsignedOG_05—GET /api/orgs/{org}/membersaccepts MSign; 401 if unsignedOG_06—DELETE /api/orgs/{org}/members/{handle}accepts MSign; 401 if unsignedOG_07—create_orgservice: addget_org(session, handle)function that returnsMusehubIdentity | None; used by the new read endpoint
Files touched:
musehub/services/musehub_orgs.py— addget_orgmusehub/api/routes/api/orgs.py— swap auth dependency, add GET route
Phase 2 — CLI: muse hub org subcommand tree
New file muse/cli/commands/hub/orgs.py. All commands use MSign (same pattern as
hub repos.py, hub issues.py). Register in muse/cli/commands/hub/__init__.py.
Command surface
muse hub org create --handle acme --display-name "Acme Inc" [--quorum 2] [--json]
muse hub org read acme [--json]
muse hub org member add acme gabriel [--weight admin|write|read] [--json]
muse hub org member remove acme gabriel [--json]
muse hub org member list acme [--json]
JSON output shapes
org create / org read:
{
"identity_id": "sha256:...",
"handle": "acme",
"identity_type": "org",
"display_name": "Acme Inc",
"quorum": 2,
"created_at": "<iso8601>"
}
org member list:
{
"org": "acme",
"members": [
{
"from_handle": "gabriel",
"to_handle": "acme",
"edge_type": "member_of",
"weight": "admin",
"authorized_by": []
}
]
}
Deliverables
OG_08—muse hub org createcallsPOST /api/orgs, returns JSON withidentity_id,handle,identity_type,display_name,quorum,created_atOG_09—muse hub org createwith duplicate handle exits 1 with clear errorOG_10—muse hub org read acmecallsGET /api/orgs/acme, returns full org JSONOG_11—muse hub org readon unknown handle exits 1 with "not found" messageOG_12—muse hub org member add acme gabrielcallsPOST /api/orgs/acme/members/gabriel, returns JSON confirmationOG_13—muse hub org member list acmecallsGET /api/orgs/acme/members, returns members arrayOG_14—muse hub org member remove acme gabrielcallsDELETE /api/orgs/acme/members/gabriel, exits 0 on successOG_15— all five commands registered underhub orginhub/__init__.py;muse hub org --helpandmuse hub org member --helpwork
Files touched:
muse/cli/commands/hub/orgs.py(new)muse/cli/commands/hub/__init__.py— import +orgs.register(subs)
Phase 3 — End-to-end verification on localhost and staging
Manual smoke test plus a lightweight integration test that calls the live server.
Localhost checklist
# Create org
muse hub org create --handle acme-test --display-name "Acme Test" --quorum 1 \
--hub https://localhost:1337 --json
# → {"identity_id": "sha256:...", "handle": "acme-test", ...}
# Read it back
muse hub org read acme-test --hub https://localhost:1337 --json
# → same fields
# Add gabriel as member
muse hub org member add acme-test gabriel --weight admin \
--hub https://localhost:1337 --json
# List members
muse hub org member list acme-test --hub https://localhost:1337 --json
# → {"org": "acme-test", "members": [{from_handle: "gabriel", ...}]}
# Verify identity repo exists with correct domain_id
muse hub repo read acme-test/identity --hub https://localhost:1337 --json
# → domain_id: "identity", visibility: "private"
# Remove member
muse hub org member remove acme-test gabriel --hub https://localhost:1337
Deliverables
OG_16— full localhost smoke test passes (create → read → member add → member list → member remove)OG_17— identity repo for org exists withdomain_id="identity",visibility="private", HEAD commit message"identity: register org acme-test"OG_18—muse hub repo list --hub https://staging.musehub.ai --jsonshowsacme-test/identityafter deploy; same smoke test passes on staging
Phase 4 — Agent guide + docs
Update ~/ecosystem/muse/docs/agent-guide.md with the muse hub org command
surface so agents can discover and use orgs without reading source.
Deliverables
OG_19—hub orgcommands added to the reflex replacement table and command reference section in agent-guide.mdOG_20— gitism glossary entry updated:muse hub create-org→muse hub org create(already documented as wrong pattern; make it correct)OG_21—muse agent-config syncrun on muse repo after agent-guide.md update; no drift reported
Acceptance criteria
muse hub org create --handle acme ...succeeds on both localhost and staging, creating an identity row withidentity_type="org"and a private identity repo withdomain_id="identity"and the correctIdentityRecord.muse hub org member addcommits aRelationshipRecordfile atrelationships/{member}/member_of/{org}.jsonin the org's identity repo.muse hub org member listreads those files from HEAD and returns the correct membership array.- All six server routes accept MSign-signed requests from the CLI.
GET /api/orgs/{org}returns the org's identity metadata (404 if not found).- Tests OG_01–OG_07 (server) green before any CLI work begins.
- Tests OG_08–OG_15 (CLI) green.
- End-to-end smoke on localhost and staging passes (OG_16–OG_18).
Out of scope
- Web UI for org management (profile page already renders orgs; no new UI needed)
- Quorum-enforced member admission via the CLI (Phase 1–2 use the service directly; multi-sig member admission is a future layer on top)
- Org-owned billing, MPay addresses, or on-chain ERC8004 registration
- Sub-org nesting (the QuorumEngine supports it; the CLI does not need to expose it yet)
muse auth keygen --entity-type org(orgs have no signing key by design)