docs / team 2026-07-31
Operations

Team & capabilities: who can do what, and how they join

How memberships carry granted capabilities, and how an invitation turns a person into a team member

Folake opens Settings on her phone and taps Team members. The list shows everyone with access to her Surulere pharmacy: herself at the top as Owner, each team member under a Job Title and a status pill, and a pending invitation as an Invite sent row. The pharmacy's whole access picture sits on one screen.

Memberships (business_memberships) #

A user acts in a business through a membership: one row that joins the user to the business. A user may hold memberships in several businesses, and each business holds one membership per user. The row carries:

  • role: owner or member, the two stored values
  • job_title: free-text display metadata, such as "Pharmacy assistant"
  • status: active or disabled

The granted capability set sits beside the row, one grant per row in business_membership_capability_grants.

The Owner is the team member with full access across the business. Every business has exactly one, created at onboarding for the verified user who creates the business. The Owner's access stays full and fixed for the life of the business, and Team settings edits apply to member rows alone. Every other team member holds role = member, is authorized from the stored grants, and comes to exist through invitation acceptance only.

Job Title names what the person does, on the team list and the member card; authorization reads the granted capability set alone.

A team member is a user who signs in to the app; an employee is a payroll record, a person the business may pay. The two records stay independent: the same person may appear on both through two separate records, and an employee may work a whole employment with zero app access.

Memberships in the bootstrap
  • GET /me returns the user's active memberships, each with the business summary, the nullable job_title, and the effective capability list: the stored grants for a member, all 18 capabilities for the Owner.
  • The app renders tabs and sections from that list alone, so a member's navigation matches the granted set from the first screen.

Capabilities (business_membership_capability_grants) #

A capability is a named protected action a team member may be granted in one business, such as recording stock movements or managing employees. The vocabulary holds 18 capabilities: 12 are grantable at launch, and 6 belong to the Owner alone. Every protected request runs the same check: resolve the acting membership, map the action to one capability, and test that capability against the membership's effective set. A member's effective set is the stored grant rows; the Owner's resolves to all 18 implicitly, with zero stored rows.

The 12 grantable capabilities, grouped by area:

AreaCapabilityCovers
Inventoryview_inventorythe area grant: products, stock levels, product detail
add_products_directlyproduct creation, live immediately
submit_products_for_approvalproduct creation as a submission for the owner's decision
create_stock_movementsrecording stock in and stock out
view_stock_historya product's movement history
Employeesview_employeesthe area grant: employee records, compensation included
manage_employeesadding and editing employees, recording pay changes
Payrollview_payrollthe area grant: run summaries and totals
create_payroll_draftscreating and recomputing draft runs, excluding and including items
review_payrollper-employee pay breakdowns
view_payslipspayslips from finalized runs
share_payslipsthe payslip share affordance

The server validates every grant set for coherence before storing it: a capability inside an area needs the area grant, share_payslips also needs view_payslips, and the two add-product capabilities form a pair of which one member holds at most one. Stored access always makes sense, and runtime checks stay flat.

The 6 Owner-only capabilities:

CapabilityCovers
manage_business_settingsthe Business Profile and the Default Location
manage_membersthe team surface: the list, member edits, and invitations
manage_productsproduct edit, deactivation, reactivation, and deletion
finalize_payrolllocking a draft run into the finalized record
view_audit_historythe business's full audit history
decide_product_approvalsdeciding submitted products

The six live in the same vocabulary and validate through the same machinery, so a later slice opens any of them to member grants with a toggle alone. The paired add-product capabilities and decide_product_approvals meet in the approval workflow, which has its own doc.

The permission form and grant writes
  • The member detail and the invite flow share one permission form. Area toggles for Inventory, Employee, and Payroll access reveal their capability rows only while on, and hidden selections drop from the saved set, so every save passes the coherence rules.
  • "Add products" carries the paired modes as two radio choices; turning the row on defaults to the submit-for-approval mode.
  • "Prepare payroll drafts" carries create_payroll_drafts and review_payroll together, so a draft preparer can also read the breakdowns the draft produces.
  • Saves send the full replacement grant set. Unknown values fail with 422 validation_error; an incoherent set fails with 422 invalid_capability_grants and per-item detail codes.

What a member sees #

The server enforces every capability check, and the app renders from the same capability facts, so the screens and the checks agree. A member's app follows four render states:

Render stateWhenOn screen
Owner-only actionthe action's capability is Owner-only at launchthe screen omits the action
Blocked actiona grantable action outside the member's setthe control renders disabled; a tap opens the Permission needed dialog
Locked sectiona read surface whose viewing grant the member lacksthe section keeps its place and shows an inline explanation in place of data
Owner handoffan Owner-only action that is the next step of the member's own workflowthe control renders disabled; the tap points to the owner

The Permission needed dialog names the missing access and directs the member to ask the business owner to update it. The Owner handoff is the narrow exception to omission: finalizing payroll on a draft the member prepared renders Finalize disabled, and the tap explains that only the business owner can finalize.

Attention items follow area grants: low-stock rows need view_inventory, and payroll-readiness rows need view_payroll or view_employees. The Recent Activity preview scopes by event family the same way; the audit doc carries that model.

Inviting a team member (business_invitations) #

An Invitation is the owner's pending offer for a person to join the business as a team member. The row carries:

  • the invited identifier: an email address, a phone number, or both, with at least one required; email stores lowercased, phone in E.164 form
  • job_title: the Job Title the new member will carry
  • the initial capability set, one row per grant in business_invitation_capability_grants, validated by the same coherence rules
  • status: pending, accepted, revoked, or expired
  • expires_at: seven days from create or resend

The capability set defaults to empty. A new person starts locked down and gains access only when the owner grants it, before sending or any time after joining.

Every created Invitation mints two credentials that redeem it:

  • the Share Link, a URL carrying a high-entropy opaque token
  • the Invite Code, six characters from the Crockford base32 alphabet; the alphabet keeps out the four confusable letters I, L, O, and U, so a code survives a phone call or a piece of paper

The link is the low-friction path where links travel well. The code covers the places they travel poorly: a messaging app that mangles a URL, a person reachable by voice alone, a code copied off paper. The app shows the code dashed for reading, GK1-482; the canonical form is the plain six characters.

Delivery is personal by design: create and resend return the raw link and code exactly once, and the owner sends them over WhatsApp, SMS, or email, in the owner's own channels, so the invited person hears about the business from the owner they already know. Tomero persists keyed hashes of both credentials alone; the raw values exist in those two responses only.

While an Invitation is pending, the owner can edit its Job Title and capability set. The invited identifier stays fixed for the life of the Invitation; correcting an address means revoking and re-inviting. Resend regenerates both credentials, resets the seven-day expiry, and returns an expired Invitation to pending; the old link and old code die the moment the new ones exist. Revoke closes the Invitation for good. One pending Invitation may exist per identifier per business, and an identifier already on a membership of the business, active or disabled, answers a typed conflict instead of a second offer.

Past expires_at the stored status stays pending: reads derive the Invite expired display from the timestamp, and the row transitions to expired lazily when someone tries the dead credential.

Invitation writes on the wire
  • POST /invitations takes email, phone_number, job_title, and capabilities, behind manage_members. The 201 response carries the raw token, the raw code, and the composed share_url exactly once. Duplicate identifiers answer 409 identifier_already_member or 409 invitation_already_pending.
  • PATCH /invitations/{invitation_id} edits job_title and the full-replacement capabilities on stored-pending Invitations; other statuses answer 409 invitation_not_pending.
  • POST /invitations/{invitation_id}/resend regenerates the token and the code and returns them raw once more. It works from stored pending or expired status; accepted or revoked Invitations answer 409 invitation_not_resendable. Invitations minted before Invite Codes existed stay valid through their Share Link and mint their first code on resend.
  • POST /invitations/{invitation_id}/revoke is replay-safe: a second revoke returns 200 and leaves the audit history unchanged. An accepted Invitation answers 409 invitation_already_accepted.
  • Each effective write emits one audit event with the invited identifiers masked in metadata: member.invited, member.invitation_updated, member.invitation_resent, or member.invitation_revoked. Raw tokens and raw codes stay out of storage, logs, and audit rows.

Joining a business #

Folake brings Ngozi onto the team in two steps from the same Settings section: who Ngozi is, a phone number and the Job Title "Pharmacy assistant"; then what Ngozi can access, the Inventory area with Update stock, stored as view_inventory and create_stock_movements. Send invite creates the Invitation and hands the share message to the phone's share sheet, and Folake sends it to Ngozi on WhatsApp herself.

Ngozi opens the app and taps Join a business. She types the six characters from Folake's message into the code boxes. Typed codes fold to canonical form before lookup: lowercase turns uppercase, dashes strip, a typed I or L reads as 1, and a typed O reads as 0, so a code read aloud and retyped imperfectly still resolves.

Continue previews the Invitation. Credential possession is the authority here: the preview runs unauthenticated and shows the business name, Folake's name as the inviter, the Job Title, the granted access areas, and the invited identifier in masked form. The masking is deliberate. A six-character code sits in a space small enough to probe, so a guessed code discloses a hint alone; the full address stays with the person it belongs to.

Ngozi creates her account from the preview. The signup locks the identifier field to the invited channel, phone in her case, and hints the masked number; the register request carries the code, so the server checks the typed number against the Invitation before any account exists. Acceptance itself is strict: the accepting account's verified login email or phone must equal an invited identifier. The match is what keeps a forwarded link safe; the message can travel anywhere, and only the addressed person can join. An invited person who already uses Tomero signs in from the flow instead and confirms the join; memberships in other businesses leave acceptance open, so one login serves several businesses.

After verification, Join business accepts the Invitation. One transaction creates Ngozi's active membership with the Job Title and the copied grants, marks the Invitation accepted with who accepted and when, and emits a member.joined audit event. The response carries the full membership context, so the app lands her in the pharmacy's workspace with her granted areas live from the first screen.

Acceptance is single-use, and it resolves both credentials at once: the same row status that admits Ngozi kills the link and the code together, exactly as revoke, resend, and expiry do. Dead credentials then answer exactly like unknown ones: unknown, revoked, accepted, and expired credentials all answer the same plain 404 on preview and accept, by link and by code alike. A distinct answer for a dead code would confirm the code once existed and invite probing; the uniform answer, plus a throttle of 10 redemption requests per 60 seconds per client address, closes that surface. Owner-facing routes keep their typed conflicts; the collapse applies to credential redemption alone.

Redemption on the wire
  • POST /invitations/preview and POST /invitations/accept sit outside the business scope and take exactly one credential in the request body, token or code, so raw credentials stay out of path and query logs. Responses omit the credential.
  • Preview returns business_name, invited_by_full_name, job_title, masked_email, masked_phone_number, capabilities in stable vocabulary order, and expires_at.
  • Accept requires a bearer token for an active verified user. A mismatched identity answers 403 invitation_identifier_mismatch; an existing membership in the business, active or disabled, answers 409 already_a_member.
  • Unknown and dead credentials answer 404 invitation_not_found on both routes. A stored-pending Invitation past expires_at flips to expired first; the request then fails the same way.
  • Both routes spend from one per-address budget, 10 requests per 60 seconds; exceeding it answers 429 rate_limited. A registration carrying an invite_code spends from the same budget.
  • A six-character code carries roughly 30 bits of entropy. Hashing at rest, single use, the seven-day expiry, strict identity matching, and the shared throttle bound the exposure.
  • Accept returns the created membership context, id, business summary, role, job_title, effective capabilities, and status, matching the bootstrap contract, so the app enters the workspace in one step.

The team list and member lifecycle #

The Team members list is one merged read: membership rows and pending invitation rows together, each carrying a kind marker of member or invitation. The owner sits first, then members by membership creation time, then invitations by creation time. An invitation row shows the invited email or phone with a derived display status, Invite sent while the credentials are live and Invite expired past expires_at; accepted and revoked Invitations leave the list. Every team route sits behind manage_members, so the team surface belongs to the owner alone at launch.

An owner can:

  • invite a person by email or phone, with a Job Title and an initial capability set
  • edit, resend, or revoke a pending Invitation
  • edit a member's Job Title
  • replace a member's capability set
  • deactivate a member, and reactivate the member later with grants and Job Title intact

The owner's own row reads as Owner with a fixed full-access summary, and member-management writes target member rows alone; a write aimed at the owner membership answers a conflict.

Deactivation ends access immediately. The membership flips to disabled, and every later business-scoped request from that member fails, a live access token included, because the server resolves the acting membership on each request. The person's user account, sign-in, and memberships in other businesses stay whole. Reactivation restores the stored grants and Job Title unchanged. Membership rows persist for the record; removal from a team is deactivation.

Team writes need a connection and apply last-write-wins; the owner is the single team writer at launch. Only a save that changes a stored value commits and emits an audit event: member.updated with the changed fields and the grant deltas, member.deactivated, or member.reactivated.

Team reads and writes on the wire
  • GET /members lists the merged rows with cursor pagination, default 50 and max 100 per page; cursors page seamlessly across the membership and invitation boundary. Search stays client-side at launch.
  • GET /members/{membership_id} returns identity facts, job_title, status, capabilities, and timestamps. The owner row carries capabilities: null, the full-access marker; clients render the fixed summary copy from it.
  • PATCH /members/{membership_id} edits any of job_title, capabilities as a full replacement, and status as active or disabled. job_title may be null to clear it. A PATCH aimed at the owner membership answers 409 owner_membership_immutable.
  • A member calling any team route answers 403 forbidden; a caller with zero membership in the business answers 404 business_context_not_found, and membership ids from another business answer 404 membership_not_found, so existence stays private across businesses.
  • Team writes skip idempotency keys and version preconditions.

Launch boundaries #

The model is built so later workflows arrive as additions. The boundaries today:

  • Every business has exactly one Owner, created at onboarding; ownership stays with that account, and the Owner's access stays fixed.
  • Six capabilities stay Owner-only at launch. The grant machinery already covers them, so later slices open them to members with a toggle alone.
  • Delivery is owner-personal only: Tomero mints the Share Link and the Invite Code, and the owner sends them. A delivery-adapter seam stays reserved for a future provider.
  • Invitation edit, resend, and revoke ship in the API today; the mobile team list renders invitation rows read-only until that surface lands.
  • A member's Job Title and capability set change through the owner alone.
  • Team writes need a connection; the offline queue covers product creation and employee creation alone.
  • One login may hold memberships in several businesses; the app opens one business's workspace at a time.