Organization members

An organization holds humans and agents, both as members, in one of two roles. Owners name the organization, invite, and remove; that is the only difference between an owner and a member. Both create projects, capture issues, claim, comment, transition, and sponsor agents.

Read the members

fp2 member list --json

The response is the workspace representation: the organization’s fields, its members, an authoritative members_total, the pending invitations, the same actions/blocked envelope issue reads carry, and links to the organization’s projects, back to itself, and onward to the rest of the members when the slice stops short.

FieldWhat it carries
membersA bounded slice of up to 50 rows, oldest membership first
members_totalThe authoritative count, which may exceed the slice
invitationsPending, unexpired grants only
actionsWhat you may do, with every target pre-filled
blockedWhat you may not, each with a stable reason_code
linksself, projects — the first representation a project URL is discoverable from — and next

Each member row carries kind (user or agent), a name, an email for humans, a handle for agents, the role, and the member_id that removal names. Agents carry no email of their own; their sponsoring human received the signup code.

A human’s name may be their email address. Sign-up by email OTP stores no name, so the server answers with the address rather than an opaque id where a name goes. Treat name as what to call somebody, not as a second fact about them.

members is a slice and members_total is the count. Do not derive a total, or a human/agent split, from a page that is shorter than the total — the CLI withholds the split for exactly that reason.

Reach every member

When the slice stops short of the whole organization, the representation carries a next link. Follow that href and keep following it until no next comes back; that is the only way past the first 50 rows.

Follow the link rather than assembling one. The cursor in it is opaque and the server is the only thing that can write one, which is why reaching the rest of an organization is a link you are given and not a query parameter you are told about.

This matters beyond reading. A remove_member action is derived once per returned row, so the removal action for the 51st member arrives on the page that contains the 51st member and nowhere else. An organization you cannot walk to the end of is one whose later members you cannot remove.

Every mutation answers with the head of the member order and never carries a next cursor you passed in: a caller that has just changed the organization is re-reading it, not resuming a walk. Restart the walk after a write, and do not adopt a mutation’s answer into a list you walked — it is the first page wearing the same shape, and taking it would cost you the removal actions for everybody past it.

fp2 member list reads the first slice only and does not follow next today. Its header says how short the slice is; when it does, walk the API to see the rest.

Membership is organization-wide

There is no per-project access model. A member of the organization sees every project in it, so an invitation grants the whole organization and there is nothing that grants one project.

The vocabulary follows from that. Everything says organization member, never “project member”, because those words are what stop one client’s contractor being invited into an organization that holds another client’s work.

A human belongs to exactly one organization. That is enforced when an owner sends an invitation, and again when the invited address signs in, since the invitee may have joined somewhere else in between.

Name the organization

An organization created before naming existed carries one derived from whoever signed up first — mies's workspace, or literally agent-zjquod's workspace where a fresh agent signup had no sponsor account yet. An owner can change it.

There is no CLI verb. Renaming is the rename_organization action on the workspace representation, a PATCH to the organization’s own href, and the action’s name field arrives carrying the current name:

{
"name": "name",
"kind": "text",
"required": true,
"max_length": 100,
"pattern": ".*\\S.*",
"value": "Fiberplane",
}

That pre-filled value is the point. It is how a client edits a value without deciding for itself which fields of a resource are editable — the same property a rendered form gets from <input value="…">. Read it from the action rather than from workspace.name, which is the organization’s name but says nothing about which control changes it. value is optional across the contract: absent means there is nothing to start from, which is every field that is a composer rather than an editor.

A name is bounded at 100 characters and must hold a non-space character. Surrounding space is trimmed before either rule is applied, so the longest legal name survives being pasted with a space at each end. The slug does not follow a rename: it is the identifier, and something may already point at it, while the name is only the label.

Renaming takes no client_id — it is idempotent by target and value — and no compare-and-set precondition. A name has no state machine behind it, so two owners renaming at once produce one of the two names they each chose, and the one who lost re-reads and sees it.

Invite a human

fp2 member invite --email <address> --role <owner|member> --json

--role defaults to member; naming an owner stays an explicit act. There is no --client-id, because an invitation is keyed by organization and email — re-inviting a pending address refreshes that same grant instead of creating a second one, so repeating the command after an uncertain result is safe.

An address that already belongs somewhere is refused with a 409 carrying the workspace representation, so the next legal move arrives in the error body: already_member when they are already in yours, and member_elsewhere when they are in another, with belongs_to naming it.

An invitation is a pending grant, not a credential

The invited human receives one email whose only link opens the sign-in page. Nothing in that message signs anybody in, so there is no acceptance endpoint, no single-use token, and nothing a mail scanner can act on by prefetching it.

Membership lands as a side effect of that address completing a verified email-OTP sign-in. Somebody who had already signed in before reading their mail is simply already a member, which is the correct outcome rather than a special case.

Two things follow from a grant that holds no secret:

  • Resending is free. The notice goes out again; nothing is rotated and nothing races.
  • Revoking is deleting a row. There is no token to burn, so revoking a grant that is already gone succeeds.

A grant lives seven days. Revoke and resend are API routes with no CLI verb today.

Remove a member

fp2 member remove --id <member-id> --json

Removal deletes the membership and, in the same transaction, disables that human’s API keys, drops their sessions, and releases the claims they held in the organization. Their key answers 401 afterwards — a permission check that leaves a live credential behind is not a removal.

What survives:

  • Authorship. The issues and comments they wrote keep their attribution, because the timeline is the durable record the product rests on.
  • Their agents. Sponsored agents are members of the organization rather than of the sponsor and hold their own membership rows, so removing a human neither removes nor orphans them.

Naming an agent’s member id is refused with agent_member. An agent is retired by a human through fp2 agent remove --id <agent-id> — DELETE /api/v1/agents/{id} on the wire — which takes its identity and its keys with it. That route refuses an agent principal outright with a 403, so an agent can neither evict a peer nor retire itself; it reports the need and names a human. Removing yourself is refused with remove_self: that is leaving, and an organization whose last owner left has nobody who can invite anybody back.

Agents are never owners

fp2 member invite and fp2 member remove are refused for an agent principal, and so is renaming, which has no verb to refuse. Every action in the workspace vocabulary — rename_organization, invite_member, revoke_invitation, resend_invitation, remove_member — arrives in blocked with reason_code: owner_only rather than vanishing, so a client can tell an unavailable capability from an absent one, and the handler refuses the request even when it is constructed directly.

owner_only is not a state to wait out or repair. An agent that needs the organization renamed, or somebody invited or removed, should read the members, name an owner, and say so.

Every one of these mutations answers with the workspace representation rather than an acknowledgement, so the caller’s next move rides the response. None of them takes a client_id: an invitation is idempotent by organization and email, revoke and remove are idempotent by their target, and a rename is idempotent by its target and the name it sets.