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:ownerormember, the two stored valuesjob_title: free-text display metadata, such as "Pharmacy assistant"status:activeordisabled
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 /mereturns the user's active memberships, each with the business summary, the nullablejob_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:
| Area | Capability | Covers |
|---|---|---|
| Inventory | view_inventory | the area grant: products, stock levels, product detail |
add_products_directly | product creation, live immediately | |
submit_products_for_approval | product creation as a submission for the owner's decision | |
create_stock_movements | recording stock in and stock out | |
view_stock_history | a product's movement history | |
| Employees | view_employees | the area grant: employee records, compensation included |
manage_employees | adding and editing employees, recording pay changes | |
| Payroll | view_payroll | the area grant: run summaries and totals |
create_payroll_drafts | creating and recomputing draft runs, excluding and including items | |
review_payroll | per-employee pay breakdowns | |
view_payslips | payslips from finalized runs | |
share_payslips | the 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:
| Capability | Covers |
|---|---|
manage_business_settings | the Business Profile and the Default Location |
manage_members | the team surface: the list, member edits, and invitations |
manage_products | product edit, deactivation, reactivation, and deletion |
finalize_payroll | locking a draft run into the finalized record |
view_audit_history | the business's full audit history |
decide_product_approvals | deciding 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_draftsandreview_payrolltogether, 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 with422 invalid_capability_grantsand 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 state | When | On screen |
|---|---|---|
| Owner-only action | the action's capability is Owner-only at launch | the screen omits the action |
| Blocked action | a grantable action outside the member's set | the control renders disabled; a tap opens the Permission needed dialog |
| Locked section | a read surface whose viewing grant the member lacks | the section keeps its place and shows an inline explanation in place of data |
| Owner handoff | an Owner-only action that is the next step of the member's own workflow | the 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, orexpiredexpires_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 /invitationstakesemail,phone_number,job_title, andcapabilities, behindmanage_members. The201response carries the rawtoken, the rawcode, and the composedshare_urlexactly once. Duplicate identifiers answer409 identifier_already_memberor409 invitation_already_pending.PATCH /invitations/{invitation_id}editsjob_titleand the full-replacementcapabilitieson stored-pending Invitations; other statuses answer409 invitation_not_pending.POST /invitations/{invitation_id}/resendregenerates the token and the code and returns them raw once more. It works from storedpendingorexpiredstatus; accepted or revoked Invitations answer409 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}/revokeis replay-safe: a second revoke returns200and leaves the audit history unchanged. An accepted Invitation answers409 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, ormember.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/previewandPOST /invitations/acceptsit outside the business scope and take exactly one credential in the request body,tokenorcode, 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,capabilitiesin stable vocabulary order, andexpires_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, answers409 already_a_member. - Unknown and dead credentials answer
404 invitation_not_foundon both routes. A stored-pending Invitation pastexpires_atflips toexpiredfirst; 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 aninvite_codespends 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, effectivecapabilities, andstatus, 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 /memberslists 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 carriescapabilities: null, the full-access marker; clients render the fixed summary copy from it.PATCH /members/{membership_id}edits any ofjob_title,capabilitiesas a full replacement, andstatusasactiveordisabled.job_titlemay be null to clear it. A PATCH aimed at the owner membership answers409 owner_membership_immutable.- A member calling any team route answers
403 forbidden; a caller with zero membership in the business answers404 business_context_not_found, and membership ids from another business answer404 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.