Replace the entity slugs on organizations, workspaces, resource pools, and plan ladders with nullable `key` columns and add keys to products, prices, and entitlement sets. Rename `providers.slug` to `provider` and add partial unique indexes for system and org role names. Assign invoice numbers per billing account from a gapless transactional counter; Stripe's number moves to the invoice mapping as an external reference. Seeds, fixtures, and the operator lookup address rows by key, and the returning-login resync no longer blanks a display name when the IdP sends no `name` claim.
12 KiB
organization
Purpose
Defines the organization model: organizations, members, workspaces, roles, and scoped role assignments.
Requirements
Requirement: Organizations table
The system SHALL maintain an organizations table with fields: org_id (UUIDv7 primary key, DEFAULT uuidv7()), name (VARCHAR(255), NOT NULL), key (TEXT, NULL, UNIQUE), org_type (VARCHAR(20), NOT NULL, FK → organization.org_types(org_type)), owner_person_id (UUID, FK → persons, NOT NULL), status (VARCHAR(20), NOT NULL, DEFAULT 'active'), created_at (TIMESTAMPTZ), updated_at (TIMESTAMPTZ). key is the organization's declarative address (ui-vocabulary; migration 00012_entity_keys, renamed from the retired slug column): a nullable, unique string a seed, a configuration file, a test, a script, or an API client uses to reach the row without knowing its generated org_id, matching ^[a-z][a-z0-9_]*$ and at most 64 characters (chk_organizations_key_grammar). Identity remains org_id; presentation remains name. NULLs are distinct in Postgres, so organizations without a key never collide; the system writes one only as a fixed constant for a row it creates by design, and a personal organization therefore carries none. Exactly one organization SHALL carry org_type = 'system' (the System tenant), enforced by a partial unique index on org_type where org_type = 'system', and that organization SHALL carry key = 'system'.
Scenario: Second System organization rejected
- WHEN an attempt is made to create a second organization with
org_type = 'system' - THEN the database SHALL reject the insert with a unique constraint violation on the partial index
Scenario: Organization keys are unique where present and absent where not chosen
- WHEN two organizations are created with no key
- THEN both inserts SHALL succeed, because NULL keys do not collide
- WHEN a second organization is created with a key another organization already carries
- THEN the database SHALL reject the insert with a unique constraint violation on
uq_organizations_key
Scenario: A key that breaks the grammar is refused
- WHEN an attempt is made to set an organization's key to a value containing an uppercase letter, a hyphen, a leading digit, or more than 64 characters
- THEN the database SHALL reject the write with a check constraint violation on
chk_organizations_key_grammar
Scenario: Organization has an owner
- WHEN an organization is created
- THEN it SHALL reference a
personsrecord asowner_person_id
Scenario: Organization type referential integrity
- WHEN an attempt is made to create an organization with an
org_typethat does not exist inorg_types - THEN the database SHALL reject the insert with a foreign key constraint violation
Requirement: Organization members
The system SHALL maintain an org_members table with fields: org_member_id (UUIDv7 primary key), org_id (UUID, FK → organizations, NOT NULL), person_id (UUID, FK → persons, NOT NULL), role_id (UUID, FK → roles, NOT NULL), status (VARCHAR(20), NOT NULL, DEFAULT 'active'), joined_at (TIMESTAMPTZ, NOT NULL, DEFAULT NOW()), removed_at (TIMESTAMPTZ), created_at (TIMESTAMPTZ), updated_at (TIMESTAMPTZ). The combination of org_id and person_id SHALL be unique (a person can be a member of an org only once).
Scenario: Person joins organization
- WHEN an
org_membersrecord is created linking a person to an organization with a role - THEN the person SHALL be considered a member of that organization with the permissions granted by the assigned role
Scenario: Duplicate membership prevented
- WHEN an attempt is made to add a person to an organization they already belong to
- THEN the database SHALL reject the insert with a unique constraint violation on
(org_id, person_id)
Requirement: Workspaces
The system SHALL maintain a workspaces table with fields: workspace_id (UUIDv7 primary key), org_id (UUID, FK → organizations, NOT NULL), name (VARCHAR(255), NOT NULL), key (TEXT, NULL), description (TEXT), status (VARCHAR(20), NOT NULL, DEFAULT 'active'), created_at (TIMESTAMPTZ), updated_at (TIMESTAMPTZ). A workspace is a child entity, so its key is unique within its organization (uq_workspaces_org_id_key on (org_id, key)) under the same grammar as every other key (^[a-z][a-z0-9_]*$, at most 64 characters, chk_workspaces_key_grammar); its declarative address is its organization's key plus its own. The key is nullable and the system never derives one, so the workspace an organization is provisioned with carries none and is addressed through its organization. Workspace names SHALL be unique within an organization, compared case-insensitively among workspaces whose status is not deleted (a live partial unique index on (org_id, lower(name)), uq_workspaces_org_id_name_ci).
Scenario: Workspace belongs to organization
- WHEN a workspace is created
- THEN it SHALL reference exactly one organization via
org_id
Scenario: The same workspace key is free in another organization
- WHEN two organizations each hold a workspace keyed
main - THEN both inserts SHALL succeed, because the key is unique per organization, not per table
Scenario: Workspace name unique within org
- WHEN an attempt is made to create a workspace whose name matches, ignoring case, a non-deleted workspace in the same organization
- THEN the database SHALL reject the insert with a unique constraint violation on
(org_id, lower(name))
Scenario: A deleted workspace frees its name
- WHEN a workspace with
status = 'deleted'carries a name - THEN a new workspace with that name in the same organization SHALL be accepted
Requirement: Roles with flat permission model
The system SHALL maintain a roles table with fields: role_id (UUIDv7 primary key), org_id (UUID, FK → organizations, nullable), role_name (VARCHAR(100), NOT NULL), display_name (VARCHAR(255), NOT NULL), description (TEXT), is_system (BOOLEAN, NOT NULL, DEFAULT FALSE), permissions (TEXT[], NOT NULL), created_at (TIMESTAMPTZ), updated_at (TIMESTAMPTZ). System roles (is_system = TRUE) have org_id = NULL. Custom roles have org_id set. role_name is the table's key (ui-vocabulary): roles are a vocabulary, so the ratified column name stands rather than a second key column being added, and the value is unique among system roles ignoring case (uq_roles_system_role_name, a partial unique index where org_id IS NULL) and unique per organization among custom roles ignoring case (uq_roles_org_role_name on (org_id, lower(role_name)) where org_id IS NOT NULL), replacing the uniqueness that held by convention only.
Scenario: System roles are seeded
- WHEN the organization module's migration runs
- THEN the following system roles SHALL exist:
owner,admin,member,billing,viewer,platform_admin - AND each role SHALL have the permission arrays defined in the design's role specification
- AND all system roles SHALL have
is_system = TRUEandorg_id = NULL
Scenario: Permission format
- WHEN a role's permissions are queried
- THEN each permission SHALL be a string in
resource:actionformat (e.g.,billing:manage,workspace:delete)
Scenario: Duplicate role names are refused
- WHEN an attempt is made to insert a second system role named
owner, or a second custom role namedEditorin the same organization - THEN the database SHALL reject the insert with a unique constraint violation on the matching partial index
Requirement: Scoped role assignments
The system SHALL maintain a role_assignments table with fields: assignment_id (UUIDv7 primary key), role_id (UUID, FK → roles, NOT NULL), person_id (UUID, FK → persons, NOT NULL), org_id (UUID, FK → organizations, NOT NULL), scope_type (VARCHAR(20), NOT NULL — values: organization, workspace, pool), scope_id (UUID, NOT NULL), created_at (TIMESTAMPTZ), updated_at (TIMESTAMPTZ). The combination of role_id, person_id, org_id, scope_type, and scope_id SHALL be unique.
Scenario: Organization-scoped assignment
- WHEN a role assignment is created with
scope_type = 'organization'andscope_idequal to anorg_id - THEN the person SHALL have that role's permissions for the entire organization
Scenario: Workspace-scoped assignment
- WHEN a role assignment is created with
scope_type = 'workspace'andscope_idequal to aworkspace_id - THEN the person SHALL have that role's permissions for that specific workspace only
Requirement: Organization module owns its schema
The organization module SHALL own its database schema via migrations in internal/organization/migrations/. The organization module's sqlc configuration SHALL read both identity and organization migrations to resolve cross-module FK references. It SHALL generate into the internal/organization/ Go package.
Scenario: Organization migration creates tables
- WHEN the organization module's migration runs (after identity migration)
- THEN the
organizations,org_members,workspaces,roles, androle_assignmentstables SHALL exist with all specified columns, constraints, and indexes
Requirement: Updated at triggers
The system SHALL automatically update the updated_at timestamp on organizations, org_members, workspaces, roles, and role_assignments rows whenever they are modified.
Scenario: Organization record updated
- WHEN any field on an
organizationsrecord is modified - THEN the
updated_atfield SHALL be set to the current timestamp via a database trigger
Requirement: Operator presentation of organizations is honest about what the system knows
Operator surfaces presenting organizations, memberships, and persons SHALL NOT render state the console does not master or evaluate as if it were live state:
- The organizations list SHALL NOT render
organizations.statusas a colored state badge while nothing in the system reads that column; the column is omitted or rendered as neutral text until a real lifecycle consumes it. - Each organization SHALL display its owner (the guaranteed owner membership) on the list and the detail surface, so an organization cannot read as unowned.
- The reserved System organization SHALL carry a visible synthetic marker distinguishing it from member organizations, and its row SHALL NOT invite ordinary member-org actions as if it were one.
- Membership role badges SHALL NOT imply console authorization: where a database role (e.g.
owner) is displayed, the surface SHALL make clear it is an organizational fact, not a permission grant (authorization derives from IdP roles at login). - Org types SHALL render once, by display name; concatenations that double display name and slug (e.g. "Personal personal") are retired.
- Person surfaces SHALL NOT badge a person as
activeor otherwise imply account-enablement knowledge that lives in the identity provider.
Scenario: Organization rows show an owner
- WHEN an operator views the organizations list
- THEN each row identifies the organization's owner person
Scenario: Unread status is not badged
- WHEN an operator views the organizations list
- THEN no green state badge renders from the unread
statuscolumn
Scenario: System organization is marked synthetic
- WHEN an operator views a listing that includes the reserved System organization
- THEN the row carries a visible marker naming it as the synthetic system tenant
Scenario: Org type renders once by display name
- WHEN an operator views any surface rendering an organization's type
- THEN the type renders as its display name a single time, with no appended slug duplicate