Organizations (S30)
Organizations as first-class repo owners. The principals table
unifies /{slug} resolution across users + orgs so a slug collision
is structurally impossible at the DB layer.
Schema (migration 0034)
orgs — slug citext, plan, allow_member_repo_create, suspended/deleted
org_members — (org_id, user_id) → role enum('owner','member')
org_invitations — pending invites by username OR email; HMAC-hashed tokens
principals — (slug citext PK, kind enum('user','org'), id) maintained
by AFTER triggers on users + orgs
The repos.owner_org_id FK to orgs.id is added now (the column was
already present from 0017 with the XOR CHECK).
Routing
GET /organizations/new create form (auth required)
POST /organizations create submit
GET /{slug} /{user-or-org} — dispatched via principals.Resolve
GET /{org}/people members + (owner-only) invite form
POST /{org}/people/invite invite by username OR email
POST /{org}/people/{userID}/role change role (owner-only)
POST /{org}/people/{userID}/remove remove member (owner-only)
GET /invitations/{token} accept/decline view (auth required)
POST /invitations/{token}/accept
POST /invitations/{token}/decline
/{slug} resolution flow inside internal/web/handlers/profile/profile.go:
- Reserved-name check (defense in depth — chi already matches static routes first).
orgs.Resolve(slug)againstprincipals. Onkind='org', dispatch toserveOrgProfile. Onkind='user', fall through to the existing user path. On miss, fall through tousername_redirectsfor renamed users.
Member roles
owner→ implicitadminon every org-owned repo (policy hook forthcoming).member→ org-membership badge; no implicit access to private repos. Repo-level access is granted via direct collaboration (S15) or teams (S31).
Last-owner protection
ChangeRole and RemoveMember both call CountOrgOwners and refuse
the operation if it would leave the org with zero owners
(ErrLastOwner). UI must surface this; the orchestrator is the
canonical enforcer.
Invitation flow
- By username: orchestrator resolves the username → user_id and checks for existing membership before issuing the invite.
- By email: stores the email; recipient claims by signing in with
any account that owns the verified email (or by signing up later
with that email — pending invites surface in the inbox via
ListPendingInvitationsForEmail).
Both paths use a 7-day expiry. Tokens are sha256-hashed at rest;
token_hash is the column we look up by.
Principals trigger
Two AFTER triggers (tg_principals_user_sync, tg_principals_org_sync)
maintain principals on every users/orgs INSERT/UPDATE/DELETE. The
slug PK on principals enforces global uniqueness across both tables
— a slug collision either with another user or another org is
rejected with SQLSTATE 23505, which the create path translates to
ErrSlugTaken.
Soft-deleted users/orgs are dropped from principals so their slug
becomes available — the username_redirects table still preserves the
old slug for 301s during the rename cooldown.
What we deferred from the spec
username_redirectsrename toprincipal_redirects. The rename +kindcolumn would cascade through every sqlc bundle (each one gets a regenerated model). Org renames aren't in the S30 DoD; deferred to a follow-up sprint that owns the rename refactor end to end.- Owner-implicit-admin in policy.Can. The
org_members.roleshape is in place; wiring it intopolicy.Canso org owners automatically getadminon org-owned repos lands when the policy refactor next touches the repo-permission resolver. - Repo creation owner picker. Repo create form still defaults
to user-owner; extending the picker to list orgs the viewer is a
member of (and honoring
allow_member_repo_create) is one follow-up handler change. - Org-level audit log surface, suspension UI, **soft-delete
- grace +
org:hard_deleteworker**, org settings page, avatar upload, email notifications for invite / role-change / remove. Schema columns are present; UI + worker land in follow-ups.
- grace +
- Org renaming via
principal_redirects— depends on the rename refactor. - Daily digest / billing / SAML — post-MVP per spec.
Pitfalls noted in code
- Slug-vs-username collision: enforced by
principalsPK, tested byTestCreate_RejectsCollisionWithUsername. - Last-owner orphaning: tested by
TestChangeRole_LastOwnerProtection. - Email-invite claim by wrong user: tested by
TestInvite_AcceptByEmailRejectsWrongUser— only an account that owns the verified email matching the invite'starget_emailcan claim. - Duplicate invitations: idempotency check returns
ErrInvitationDuplicaterather than minting a new token, so re-clicking Invite doesn't spam the recipient. - Reserved slugs:
auth.IsReservedfilter applies to org slugs the same way it applies to usernames.
View source
| 1 | # Organizations (S30) |
| 2 | |
| 3 | Organizations as first-class repo owners. The `principals` table |
| 4 | unifies `/{slug}` resolution across users + orgs so a slug collision |
| 5 | is structurally impossible at the DB layer. |
| 6 | |
| 7 | ## Schema (migration 0034) |
| 8 | |
| 9 | ``` |
| 10 | orgs — slug citext, plan, allow_member_repo_create, suspended/deleted |
| 11 | org_members — (org_id, user_id) → role enum('owner','member') |
| 12 | org_invitations — pending invites by username OR email; HMAC-hashed tokens |
| 13 | principals — (slug citext PK, kind enum('user','org'), id) maintained |
| 14 | by AFTER triggers on users + orgs |
| 15 | ``` |
| 16 | |
| 17 | The `repos.owner_org_id` FK to `orgs.id` is added now (the column was |
| 18 | already present from 0017 with the XOR CHECK). |
| 19 | |
| 20 | ## Routing |
| 21 | |
| 22 | ``` |
| 23 | GET /organizations/new create form (auth required) |
| 24 | POST /organizations create submit |
| 25 | GET /{slug} /{user-or-org} — dispatched via principals.Resolve |
| 26 | GET /{org}/people members + (owner-only) invite form |
| 27 | POST /{org}/people/invite invite by username OR email |
| 28 | POST /{org}/people/{userID}/role change role (owner-only) |
| 29 | POST /{org}/people/{userID}/remove remove member (owner-only) |
| 30 | GET /invitations/{token} accept/decline view (auth required) |
| 31 | POST /invitations/{token}/accept |
| 32 | POST /invitations/{token}/decline |
| 33 | ``` |
| 34 | |
| 35 | `/{slug}` resolution flow inside `internal/web/handlers/profile/profile.go`: |
| 36 | |
| 37 | 1. Reserved-name check (defense in depth — chi already matches static |
| 38 | routes first). |
| 39 | 2. `orgs.Resolve(slug)` against `principals`. On `kind='org'`, dispatch |
| 40 | to `serveOrgProfile`. On `kind='user'`, fall through to the |
| 41 | existing user path. On miss, fall through to `username_redirects` |
| 42 | for renamed users. |
| 43 | |
| 44 | ## Member roles |
| 45 | |
| 46 | * `owner` → implicit `admin` on every org-owned repo (policy hook |
| 47 | forthcoming). |
| 48 | * `member` → org-membership badge; no implicit access to private repos. |
| 49 | Repo-level access is granted via direct collaboration (S15) or |
| 50 | teams (S31). |
| 51 | |
| 52 | ## Last-owner protection |
| 53 | |
| 54 | `ChangeRole` and `RemoveMember` both call `CountOrgOwners` and refuse |
| 55 | the operation if it would leave the org with zero owners |
| 56 | (`ErrLastOwner`). UI must surface this; the orchestrator is the |
| 57 | canonical enforcer. |
| 58 | |
| 59 | ## Invitation flow |
| 60 | |
| 61 | * By **username**: orchestrator resolves the username → user_id and |
| 62 | checks for existing membership before issuing the invite. |
| 63 | * By **email**: stores the email; recipient claims by signing in with |
| 64 | any account that owns the verified email (or by signing up later |
| 65 | with that email — pending invites surface in the inbox via |
| 66 | `ListPendingInvitationsForEmail`). |
| 67 | |
| 68 | Both paths use a 7-day expiry. Tokens are sha256-hashed at rest; |
| 69 | `token_hash` is the column we look up by. |
| 70 | |
| 71 | ## Principals trigger |
| 72 | |
| 73 | Two AFTER triggers (`tg_principals_user_sync`, `tg_principals_org_sync`) |
| 74 | maintain `principals` on every users/orgs INSERT/UPDATE/DELETE. The |
| 75 | slug PK on `principals` enforces global uniqueness across both tables |
| 76 | — a slug collision either with another user or another org is |
| 77 | rejected with SQLSTATE 23505, which the create path translates to |
| 78 | `ErrSlugTaken`. |
| 79 | |
| 80 | Soft-deleted users/orgs are dropped from `principals` so their slug |
| 81 | becomes available — the username_redirects table still preserves the |
| 82 | old slug for 301s during the rename cooldown. |
| 83 | |
| 84 | ## What we deferred from the spec |
| 85 | |
| 86 | * **`username_redirects` rename to `principal_redirects`**. The |
| 87 | rename + `kind` column would cascade through every sqlc bundle |
| 88 | (each one gets a regenerated model). Org renames aren't in the |
| 89 | S30 DoD; deferred to a follow-up sprint that owns the rename |
| 90 | refactor end to end. |
| 91 | * **Owner-implicit-admin in policy.Can**. The `org_members.role` |
| 92 | shape is in place; wiring it into `policy.Can` so org owners |
| 93 | automatically get `admin` on org-owned repos lands when the |
| 94 | policy refactor next touches the repo-permission resolver. |
| 95 | * **Repo creation owner picker**. Repo create form still defaults |
| 96 | to user-owner; extending the picker to list orgs the viewer is a |
| 97 | member of (and honoring `allow_member_repo_create`) is one |
| 98 | follow-up handler change. |
| 99 | * **Org-level audit log surface**, **suspension UI**, **soft-delete |
| 100 | + grace + `org:hard_delete` worker**, **org settings page**, |
| 101 | **avatar upload**, **email notifications for invite / role-change |
| 102 | / remove**. Schema columns are present; UI + worker land in |
| 103 | follow-ups. |
| 104 | * **Org renaming via `principal_redirects`** — depends on the |
| 105 | rename refactor. |
| 106 | * **Daily digest / billing / SAML** — post-MVP per spec. |
| 107 | |
| 108 | ## Pitfalls noted in code |
| 109 | |
| 110 | * **Slug-vs-username collision**: enforced by `principals` PK, |
| 111 | tested by `TestCreate_RejectsCollisionWithUsername`. |
| 112 | * **Last-owner orphaning**: tested by |
| 113 | `TestChangeRole_LastOwnerProtection`. |
| 114 | * **Email-invite claim by wrong user**: tested by |
| 115 | `TestInvite_AcceptByEmailRejectsWrongUser` — only an account |
| 116 | that owns the verified email matching the invite's `target_email` |
| 117 | can claim. |
| 118 | * **Duplicate invitations**: idempotency check returns |
| 119 | `ErrInvitationDuplicate` rather than minting a new token, so |
| 120 | re-clicking Invite doesn't spam the recipient. |
| 121 | * **Reserved slugs**: `auth.IsReserved` filter applies to org |
| 122 | slugs the same way it applies to usernames. |