@turystack/modeling 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/00-overview.md ADDED
@@ -0,0 +1,96 @@
1
+ # Overview
2
+
3
+ **Rules defined here:** none — every id below is owned by another section.
4
+
5
+ ## The mental model
6
+
7
+ A Turystack product is modelled in three layers, and confusing them is the
8
+ origin of most of the damage.
9
+
10
+ **The shape** is what this skill governs: tables, columns, keys, cardinalities,
11
+ tenancy. It is the slowest thing in the system to change, because rows already
12
+ exist in it. A wrong boundary in code is a refactor; a wrong column in a table
13
+ with four million rows is a migration, a backfill, and a window where both
14
+ shapes must be true at once.
15
+
16
+ **The rules** are what the domain enforces on that shape: a second workspace is
17
+ refused, a membership cannot outlive its organization, an OTP is consumed once.
18
+ Rules live in use cases, not in the schema — but the schema decides which rules
19
+ are *possible* to express and which are merely hoped for.
20
+
21
+ **The presentation** is what an application shows about the shape: a workspace
22
+ selector, a role picker. Presentation is never where a rule is enforced
23
+ (`ARC-SEC-2`).
24
+
25
+ The line between the shape and the rules is where the expensive mistakes are.
26
+ Encoding a *policy* in the shape is the most common one: a customer who buys the
27
+ single-workspace plan does not get a different schema, because next quarter they
28
+ buy the other plan and the difference becomes a data migration instead of a
29
+ setting. See `EVO-3`.
30
+
31
+ ## The five that cost the most
32
+
33
+ 1. **A policy modelled as a shape.** "This customer only has one workspace" is a
34
+ rule. Model the general case and constrain it. `EVO-3`
35
+ 2. **A row that cannot say which customer it belongs to.** Reachable in
36
+ principle, through four joins, is not the same as scoped. `SCP-2`
37
+ 3. **Uniqueness that forgot the tenant.** `unique(slug)` across all customers
38
+ means the second customer cannot use the obvious name — and worse, that a
39
+ lookup by slug can cross a tenant. `SCP-5`
40
+ 4. **A polymorphic foreign key.** `target_type` + `target_id` buys flexibility
41
+ by giving up every referential guarantee the database offers. `REL-6`
42
+ 5. **A null with no stated meaning.** `workspace_id IS NULL` is law when the
43
+ model says it means *the whole organization*, and a bug when nobody wrote
44
+ that down. `TAB-7`
45
+
46
+ ## Namespaces
47
+
48
+ | Namespace | Owns |
49
+ | --- | --- |
50
+ | `TAB` | What becomes a table; identity, keys, columns, naming, types |
51
+ | `REL` | Cardinality, join tables, optional relationships, ownership, cascade |
52
+ | `SCP` | Tenancy: which column carries the customer, scoped uniqueness, cross-tenant reads |
53
+ | `EVO` | How a model changes once rows exist |
54
+ | `IAM` | The identity and access model |
55
+
56
+ Model namespaces are allocated as models are written: `IAM` today,
57
+ `BIL`, `NTF` and the rest as they land. A model file never redefines a
58
+ generic rule; it cites it.
59
+
60
+ ## Cited from elsewhere
61
+
62
+ These are law, and they are owned by another skill. They appear here because a
63
+ modelling decision routinely depends on them.
64
+
65
+ | Owner › Id | What it says |
66
+ | --- | --- |
67
+ | `turystack-architecture-pattern` › `ARC-SEC-1` | Scope comes from the authenticated context, never from a client field |
68
+ | `turystack-architecture-pattern` › `ARC-SEC-3` | Authorization is operation **plus** resource |
69
+ | `turystack-architecture-pattern` › `ARC-SEC-12` | A permission identifier comes from the product's single catalogue |
70
+ | `turystack-architecture-pattern` › `ARC-DAT-3` | Erasure has a declared path reaching every place the data landed |
71
+ | `turystack-architecture-pattern` › `ARC-DAT-4` | Soft delete is a state, not a synonym for erased |
72
+ | `turystack-backend-pattern` › `REP-1` | A repository uses data verbs (`find`, `save`, `update`), never business operation names |
73
+ | `turystack-backend-pattern` › `REP-L1` | Filters, sorting and pagination go through `@turystack/query-dsl` |
74
+ | `turystack-backend-pattern` › `ENT-1` | The entity owns the invariants; the use case delegates to a `checkIf…` guard |
75
+ | `turystack-backend-pattern` › `CTL-2` | One HTTP surface per consumer, each with its own prefix and OpenAPI document |
76
+ | `turystack-backend-pattern` › `ENT-5` | Field order: PK → FKs → important → less important → booleans → status → timestamps and audit |
77
+ | `turystack-backend-pattern` › `SCH-8` | The same order inside the schema |
78
+
79
+ ## How a canonical model is written
80
+
81
+ Every `1x-model-*.md` has the same five parts, and the order matters because
82
+ each part answers a question raised by the one before it.
83
+
84
+ 1. **What it is for** — the product question the model answers, in a paragraph.
85
+ A model whose purpose cannot be stated in a paragraph is two models.
86
+ 2. **The diagram** — an ASCII sketch of the entities and their cardinalities.
87
+ It is the thing people actually read, so it comes before the tables.
88
+ 3. **The tables** — column by column, with type, nullability and what a null
89
+ means — including the ones every table shares (`<table>_id`, `created_at`,
90
+ `updated_at`, `deleted_at`, the audit trio). A table you have to combine
91
+ with a legend to know its real shape is a table that gets read wrong.
92
+ 4. **Invariants** — `<MODEL>-n`, the rules that are true of this model and
93
+ that no generic rule can express.
94
+ 5. **What the CLI generates** — the exact tables, seeds and rules the scaffold
95
+ writes, so a diff between a generated repository and this file is a bug in
96
+ one of the two.
package/01-entities.md ADDED
@@ -0,0 +1,147 @@
1
+ # Entities
2
+
3
+ **Rules defined here:** `TAB-1` · `TAB-2` · `TAB-3` · `TAB-4` ·
4
+ `TAB-5` · `TAB-6` · `TAB-7` · `TAB-8` · `TAB-9` — the law is the
5
+ *Invariants* table.
6
+
7
+ ## Concept
8
+
9
+ What deserves a table, what its rows are called, and what every row carries.
10
+ This is the cheapest section to get right and the most expensive to revisit: a
11
+ column name survives longer than the team that chose it.
12
+
13
+ ## Invariants
14
+
15
+ | ID | Law (one line) | Class | Gate |
16
+ | --- | --- | --- | --- |
17
+ | TAB-1 | A table is a noun with its own identity and its own lifetime. A value that only exists as part of one parent row, and is always read with it, is a column — not a table. | constitutional | `manual` |
18
+ | TAB-2 | The primary key carries the table's name — `user_id`, `invitation_id` — and is a UUIDv7 generated by the application. A foreign key carries the same name as the key it points at, so `membership.user_id` needs no explanation. A natural key is never the primary key; it is a scoped unique constraint. | constitutional | `manual` |
19
+ | TAB-3 | Every table carries `created_at` and `updated_at`. A table whose rows can be withdrawn carries `deleted_at`, and withdrawal sets it (`ARC-DAT-4`). | constitutional | `manual` |
20
+ | TAB-4 | A closed set of values the product owns is a `text` column plus a Zod enum in the schema. Never a database enum type. A set the customer can extend is a table. The question is "can a customer add one?", not "how many are there?". | constitutional | `manual` |
21
+ | TAB-5 | Names: table singular `snake_case`; foreign key `<referenced_table>_id`; boolean `is_`/`has_`; instant `_at`; duration with its unit (`timeout_seconds`). | constitutional | `manual` |
22
+ | TAB-6 | Money is an integer in minor units, always — the column is named for the thing (`price`), not for the unit. A currency column sits beside it. Never a float. | constitutional | `manual` |
23
+ | TAB-7 | A nullable column has one stated meaning for its null, written in the model. Absence is never encoded as `''`, `0`, `-1` or the epoch. | constitutional | `manual` |
24
+ | TAB-8 | A secret is stored as a hash, never as the value (`ARC-SEC-6`), and the column says so: `password_hash`, `code_hash`. | constitutional | `manual` |
25
+ | TAB-9 | A table a person writes to carries `created_by`, `updated_by` and — where rows are withdrawn — `deleted_by` (`ARC-SEC-9`, `ENT-5`). They are `text` and not foreign keys, because the principal is not always a row in `user`: the seed and a scheduled job write too, and there the value is null. | constitutional | `manual` |
26
+
27
+ ## Why a table is a lifetime, not a shape
28
+
29
+ The temptation is to make a table out of anything with more than one field. The
30
+ question that actually decides it is: **can this thing be created, changed or
31
+ removed on its own?**
32
+
33
+ An address that belongs to one organization, is always shown with it, and dies
34
+ with it, is columns on `organization` — or a JSON column if it is genuinely
35
+ opaque. An address a customer maintains in a list, selects at checkout, and
36
+ keeps after the order is delivered, is a table: it has its own lifetime.
37
+
38
+ Getting this wrong in the cheap direction (a table that should have been
39
+ columns) costs a join and some ceremony. Getting it wrong in the expensive
40
+ direction (columns that should have been a table) costs a migration the day the
41
+ customer wants a second one — and there is always a second one.
42
+
43
+ ## Why UUIDv7 and not a sequence
44
+
45
+ Three reasons, in the order they bite:
46
+
47
+ - An identifier that arrives from the client can be validated as an identifier
48
+ without a database round trip, and cannot be guessed by incrementing.
49
+ - A row can be created by the application before the transaction commits, so a
50
+ parent and its children are built in one pass without `RETURNING` juggling.
51
+ - UUIDv7 sorts by creation time, so the index behaves like a sequence's does —
52
+ which is the one thing UUIDv4 gets badly wrong.
53
+
54
+ The natural key still matters; it is just not the primary key. An organization
55
+ has a `slug` that people type and that must be unique **within its scope**
56
+ (`SCP-5`) — and that can change without rewriting every row that points at
57
+ the organization.
58
+
59
+ The name carries the table because a join reads better than it selects. In
60
+ `membership`, `user_id` and `organization_id` say what they point at without a
61
+ lookup; with `id` as the primary key everywhere, half the columns in a
62
+ four-table query are called `id` and the reader has to track which alias owns
63
+ which. It also makes the foreign key and the key it references literally the
64
+ same name, which is what lets a schema be checked by pattern rather than by
65
+ memory.
66
+
67
+ ## Why the closed set is not a database enum
68
+
69
+ A Postgres enum looks like the honest choice: the database refuses a value
70
+ nobody declared. What it actually buys is a type that only DDL can change.
71
+
72
+ Adding a value is `ALTER TYPE … ADD VALUE`, which means the deploy that
73
+ introduces `SUSPENDED` in the code is now coupled to a migration — and the two
74
+ land at different moments, so there is a window where one of them is wrong.
75
+ Removing a value is not supported at all. Reordering is not supported.
76
+ Renaming rewrites every dependent view.
77
+
78
+ So the column is `text` and the closed set lives in the Zod schema, which is
79
+ already the contract the edge validates against (`ARC-SEC-4`). One definition,
80
+ versioned with the code that branches on it, and adding a value is a deploy.
81
+
82
+ What the database gives up is a check it was doing for free. That is the trade,
83
+ and it is worth it because the value only ever arrives through a validated
84
+ schema — a write that bypasses validation is a bug the enum would have caught
85
+ one layer too late anyway.
86
+
87
+ The rule flips the moment a **customer** can add a value. Then it is not a
88
+ closed set at all: it is a table, with an organization on it (`SCP-2`).
89
+
90
+ ## Why the null needs a meaning
91
+
92
+ `membership.workspace_id IS NULL` means *this membership grants its role across
93
+ the whole organization*. That sentence is law: queries branch on it, the admin
94
+ UI renders differently for it, and a person reading the table without it would
95
+ reasonably guess "workspace not chosen yet".
96
+
97
+ A nullable column with no stated meaning eventually acquires two, from two
98
+ different authors, and every read after that is a coin flip. If a column can be
99
+ null for more than one reason, the reasons are a state column and the null is
100
+ derived from it.
101
+
102
+ ## The order of the columns
103
+
104
+ Columns are written in the order `ENT-5` and `SCH-8` require, and this file
105
+ lists them that way for the same reason the entity and the schema do:
106
+
107
+ ```text
108
+ PK → FKs → important → less important → booleans → status → timestamps + audit
109
+ ```
110
+
111
+ The last group is `created_at`, `updated_at`, `deleted_at`, then `created_by`,
112
+ `updated_by`, `deleted_by`.
113
+
114
+ The order is not cosmetic. A model, a schema and an entity that agree top to
115
+ bottom can be read against each other in one pass, and a column added in the
116
+ wrong group is visible before anyone has to compare three files line by line.
117
+ Biome sorts keys alphabetically everywhere except the entity, where
118
+ `@turystack/backend-config` turns `useSortedKeys` off precisely so this
119
+ semantic order can exist.
120
+
121
+ ## Never do
122
+
123
+ ```text
124
+ ❌ deleted BOOLEAN a state pretending to be a fact; when was it?
125
+ ✅ deleted_at TIMESTAMPTZ NULL null means live (ARC-DAT-4)
126
+
127
+ ❌ price NUMERIC / price FLOAT rounding, and no currency
128
+ ✅ price INTEGER + currency CHAR(3) minor units, always (TAB-6)
129
+
130
+ ❌ CREATE TYPE organization_status AS ENUM (…) only DDL can change it
131
+ ✅ status TEXT + z.enum([...]) in the schema (TAB-4)
132
+
133
+ ❌ email VARCHAR PRIMARY KEY the day someone changes it, every FK breaks
134
+ ✅ user_id UUID PRIMARY KEY + unique(email)
135
+
136
+ ❌ id UUID PRIMARY KEY which id, in a query that joins four tables?
137
+ ✅ user_id UUID PRIMARY KEY the FK pointing at it has the same name (TAB-2)
138
+
139
+ ❌ expires_at DEFAULT '1970-01-01' a sentinel meaning "never"
140
+ ✅ expires_at TIMESTAMPTZ NULL null means "does not expire" — stated in the model
141
+
142
+ ❌ otp.code VARCHAR a stolen backup is a stolen set of codes
143
+ ✅ otp.code_hash VARCHAR (TAB-8, ARC-SEC-6)
144
+
145
+ ❌ created_by UUID REFERENCES user(id) the seed and the cron have no user row
146
+ ✅ created_by TEXT NULL null means no authenticated principal (TAB-9)
147
+ ```
@@ -0,0 +1,86 @@
1
+ # Relationships
2
+
3
+ **Rules defined here:** `REL-1` · `REL-2` · `REL-3` · `REL-4` ·
4
+ `REL-5` · `REL-6` · `REL-7` — the law is the *Invariants* table.
5
+
6
+ ## Concept
7
+
8
+ Which side holds the key, when a relationship becomes a table of its own, what
9
+ an optional relationship means, and who owns whose lifetime.
10
+
11
+ ## Invariants
12
+
13
+ | ID | Law (one line) | Class | Gate |
14
+ | --- | --- | --- | --- |
15
+ | REL-1 | Cardinality is declared in the model, in words, before it is written in a migration. "One organization has many workspaces; a workspace has exactly one organization" is the artefact — the foreign key is its consequence. | constitutional | `manual` |
16
+ | REL-2 | One-to-many puts the foreign key on the many side. An array column, a comma-separated string and a JSON list of ids are not relationships. | constitutional | `manual` |
17
+ | REL-3 | Many-to-many is an explicit table with its own key (`<table>_id`), its own timestamps, and a unique constraint on the pair. It is named for what it is (`membership`, `role_permission`), never `a_b`. | constitutional | `manual` |
18
+ | REL-4 | An optional relationship is a nullable foreign key whose null carries the meaning the model states (`TAB-7`). It is never a second table that exists only to represent absence. | constitutional | `manual` |
19
+ | REL-5 | Exactly one parent owns a row's lifetime, and the model names it. Deleting the owner reaches the row; deleting a non-owner never silently takes it. | constitutional | `manual` |
20
+ | REL-6 | No polymorphic foreign key. If a row can point at two kinds of parent, it holds two nullable foreign keys and a check constraint that exactly one is set. | constitutional | `manual` |
21
+ | REL-7 | A foreign key may cross domain packages only in the direction the packages already depend (`ARC-LAY-1`, `ARC-LAY-4`). Two domains that need to point at each other are one domain, or one of them holds an id without a constraint and reads through the other's API. | constitutional | `manual` |
22
+
23
+ ## Why the join table has its own id
24
+
25
+ A composite primary key on `(role_id, permission_id)` is smaller and, for a
26
+ week, simpler. Then one of these happens, and all of them happen eventually:
27
+
28
+ - someone needs to know *when* the permission was granted, and to whom;
29
+ - an audit trail needs to reference the grant itself;
30
+ - the ORM needs a stable handle for the row to update it;
31
+ - the pair has to become a triple, and every foreign key pointing at the pair
32
+ has to change shape.
33
+
34
+ The key costs 16 bytes and buys all four. The unique constraint on the pair is
35
+ what preserves the guarantee the composite key was there for — so nothing is
36
+ lost, and it is the constraint, not the key, that should have been carrying that
37
+ meaning from the start.
38
+
39
+ ## Why polymorphic keys are refused
40
+
41
+ `comment(target_type, target_id)` looks like it saves four tables. What it
42
+ actually does is move referential integrity out of the database and into the
43
+ hope that every writer remembers. There is no foreign key, so a deleted parent
44
+ leaves orphans; there is no index the planner can use across types; and every
45
+ read needs a branch before it can join.
46
+
47
+ Two nullable foreign keys with `CHECK (num_nonnulls(order_id, invoice_id) = 1)`
48
+ keeps the guarantee and reads honestly. When the count of possible parents grows
49
+ past three or four, that is the model telling you the child is really its own
50
+ concept with its own table per parent, or that the parents share a supertype
51
+ that deserves a table.
52
+
53
+ ## Ownership and cascade
54
+
55
+ Ownership is a modelling statement, not a database setting: *this row cannot
56
+ outlive that one*. A `workspace` cannot outlive its `organization`. A
57
+ `membership` cannot outlive either its `user` or its `organization` — but it is
58
+ **owned** by the organization, because that is the lifetime the product manages.
59
+
60
+ Cascade follows ownership and nothing else. A foreign key that is not ownership
61
+ — `membership.role_id` — restricts instead: a role in use cannot be deleted, and
62
+ the product has to say what happens to the memberships first. That refusal is
63
+ the point. A cascade there would silently strip people's access.
64
+
65
+ Erasure of a person is not a cascade from `user`; it is the declared path in
66
+ `ARC-DAT-3`, because the data landed in places no foreign key reaches.
67
+
68
+ ## Never do
69
+
70
+ ```text
71
+ ❌ workspace.member_ids UUID[] no constraint, no index, no join
72
+ ✅ membership(user_id, organization_id, workspace_id?) (REL-2, REL-3)
73
+
74
+ ❌ user_role(user_id, role_id) PK(user_id, role_id)
75
+ ✅ membership(membership_id, user_id, organization_id, workspace_id?, role_id,
76
+ unique(user_id, organization_id, workspace_id)) (REL-3)
77
+
78
+ ❌ attachment(owner_type, owner_id) no foreign key, no integrity
79
+ ✅ attachment(order_id?, invoice_id?) + CHECK exactly one (REL-6)
80
+
81
+ ❌ ON DELETE CASCADE on membership.role_id deleting a role strips access
82
+ ✅ ON DELETE RESTRICT — the product decides what happens first (REL-5)
83
+
84
+ ❌ user_without_workspace + user_with_workspace two tables to model a null
85
+ ✅ workspace_id NULL, meaning stated in the model (REL-4)
86
+ ```
package/03-scoping.md ADDED
@@ -0,0 +1,141 @@
1
+ # Scoping
2
+
3
+ **Rules defined here:** `SCP-1` · `SCP-2` · `SCP-3` · `SCP-4` ·
4
+ `SCP-5` · `SCP-6` · `SCP-7` — the law is the *Invariants* table.
5
+
6
+ ## Concept
7
+
8
+ Every Turystack product is multi-tenant from the first migration. This section
9
+ is about the column that carries the tenant, and about the two questions that
10
+ decide whether a leak between customers is possible at all: *can this row say
11
+ who owns it?* and *can this query be written without saying it?*
12
+
13
+ ## Invariants
14
+
15
+ | ID | Law (one line) | Class | Gate |
16
+ | --- | --- | --- | --- |
17
+ | SCP-1 | Every product row belongs to exactly one organization, and the model states the path. A table with no path to an organization is either platform-wide (a catalogue) or a modelling error, and the model says which. | constitutional | `manual` |
18
+ | SCP-2 | A row read by scope carries `organization_id` directly, even when the value is reachable through a parent. Scoping through joins is how a filter gets forgotten. | constitutional | `manual` |
19
+ | SCP-3 | The scope value comes from the authenticated context (`ARC-SEC-1`). A scope column is never populated from, or filtered by, a value the client sent. | constitutional | `manual` |
20
+ | SCP-4 | A workspace-scoped row carries both `organization_id` and `workspace_id`. The organization stays because it is the tenant; the workspace narrows within it. | constitutional | `manual` |
21
+ | SCP-5 | Every uniqueness rule includes its scope: `unique(organization_id, slug)`, never `unique(slug)`. A globally unique natural key is a deliberate, written exception. | constitutional | `manual` |
22
+ | SCP-6 | A scoped read is **one** method, never two. `organizationId` is an optional field of its input schema, and the controller decides whether it is filled (`ARC-SEC-1`): forced from the authenticated profile on a client surface, absent on an internal one. There is no separate cross-organization method. | constitutional | `manual` |
23
+ | SCP-7 | A read by id does not filter by scope. It fetches the row, and the entity's guard confirms the scope in the use case (`ENT-1`) — so a row belonging to another organization is refused by the domain rather than hidden by a `WHERE`. | constitutional | `manual` |
24
+
25
+ ## Why the column is denormalised
26
+
27
+ `invoice_line` belongs to an `invoice`, which belongs to an `order`, which
28
+ belongs to an `organization`. The tenant is reachable — three joins away. So the
29
+ scoped read is:
30
+
31
+ ```sql
32
+ SELECT il.* FROM invoice_line il
33
+ JOIN invoice i ON i.id = il.invoice_id
34
+ JOIN "order" o ON o.id = i.order_id
35
+ WHERE o.organization_id = $1
36
+ ```
37
+
38
+ Every one of those joins is a chance to omit the `WHERE`, and omitting it does
39
+ not fail: it returns more rows, quietly, in an endpoint that looks like it
40
+ works. The failure surfaces as a customer seeing another customer's data.
41
+
42
+ With `organization_id` on `invoice_line`, the scoped read is one predicate, the
43
+ index is one column, and — the part that matters — the shape makes the rule
44
+ mechanically checkable: a query against a scoped table with no `organization_id`
45
+ predicate is a finding a tool can raise, and that is impossible to check when
46
+ the tenant lives three joins away.
47
+
48
+ The cost is a denormalised column that must be written correctly on insert. That
49
+ is a single place, in the repository, versus every read forever.
50
+
51
+ ## What is not scoped
52
+
53
+ Three kinds of table legitimately have no `organization_id`, and the model must
54
+ say which one applies:
55
+
56
+ - **Catalogues owned by the code.** `permission` is seeded from the source and
57
+ is the same for every customer.
58
+ - **The person.** `user` is not scoped: one person can belong to several
59
+ organizations, and that is exactly what `membership` expresses.
60
+ - **Platform-wide records.** Rows the operator owns. They live under the
61
+ platform organization rather than under nothing, so `organization_id` is
62
+ present and points there — see `10-model-iam.md`.
63
+
64
+ Anything else with no path to an organization is unfinished modelling.
65
+
66
+ ## Cross-tenant reads
67
+
68
+ The backoffice exists to look across organizations, so the ability is real. What
69
+ the model does **not** do is give it a method of its own.
70
+
71
+ There is one read, and the scope is an optional field of its input. Who fills
72
+ that field is the controller's decision, and `ARC-SEC-1` already states the
73
+ three cases: a client surface forces it from the authenticated profile, an
74
+ operator surface forces the operator organization's, and an internal surface
75
+ leaves it out because querying across organizations is the point.
76
+
77
+ ```ts
78
+ // the read — one method, data verbs only (REP-1), input built from the
79
+ // canonical schemas through @turystack/query-dsl (REP-L1)
80
+ findMany(input: { organizationId?: string; status?: OrderStatus })
81
+ ```
82
+
83
+ ```ts
84
+ // client surface — the scope is forced, and never read from the request
85
+ async list(
86
+ @AuthenticatedProfile() profile: IamProfile,
87
+ @Request(listOrdersRequest) req: RequestInput<typeof listOrdersRequest>,
88
+ ) {
89
+ return this.listOrders.execute({ ...req.query, organizationId: profile.organizationId })
90
+ }
91
+
92
+ // internal surface — no forced scope; organizationId is an optional query
93
+ async list(@Request(listOrdersInternalRequest) req: RequestInput<typeof listOrdersInternalRequest>) {
94
+ return this.listOrders.execute(req.query)
95
+ }
96
+ ```
97
+
98
+ A second method named for crossing tenants would look safer and be worse: two
99
+ implementations of the same query, drifting apart, and a filter that has to be
100
+ remembered in one of them. One method with an optional scope keeps a single
101
+ query, and moves the decision to the one layer that knows who is asking.
102
+
103
+ What the model owes this arrangement is `SCP-2` — the column has to be on
104
+ the row, or the optional filter has nothing to filter on.
105
+
106
+ ## Reading one row
107
+
108
+ A read by id is not scoped, and that is deliberate:
109
+
110
+ ```ts
111
+ const order = await this.orderRepository.findById(input.orderId)
112
+
113
+ order.checkOrganization(input.organizationId) // ENT-1 — the entity refuses it
114
+ ```
115
+
116
+ Filtering by scope in the query answers "not found" for a row that exists and
117
+ belongs to someone else. Fetching it and letting the entity's guard refuse it
118
+ answers with the denial the situation actually is, and puts the rule in the
119
+ entity where every other invariant already lives. `find` returns one row,
120
+ `findMany` returns rows, `findPaginated` returns a page — each taking the input
121
+ its own read needs, and none of them growing a scoped variant.
122
+
123
+ ## Never do
124
+
125
+ ```text
126
+ ❌ findById(id) with no guard after it returns any customer's row
127
+ ✅ findById(id) + entity.checkOrganization(scope) (SCP-7, ENT-1)
128
+
129
+ ❌ unique(slug) customer B cannot use the obvious name
130
+ ✅ unique(organization_id, slug) (SCP-5)
131
+
132
+ ❌ where: { organizationId: input.organizationId } from the client
133
+ ✅ where: { organizationId: ctx.organizationId } (ARC-SEC-1, SCP-3)
134
+
135
+ ❌ invoice_line with no organization_id, scoped through three joins
136
+ ✅ invoice_line.organization_id, written on insert (SCP-2)
137
+
138
+ ❌ findMany() and findManyAcrossOrganizations() two queries that will drift
139
+ ✅ findMany({ organizationId?: … }) — the controller fills it, or does not
140
+ (SCP-6, ARC-SEC-1)
141
+ ```
@@ -0,0 +1,96 @@
1
+ # Evolution
2
+
3
+ **Rules defined here:** `EVO-1` · `EVO-2` · `EVO-3` · `EVO-4` ·
4
+ `EVO-5` — the law is the *Invariants* table.
5
+
6
+ ## Concept
7
+
8
+ A model with rows in it is a contract with everything already running against
9
+ it. This section is how that contract changes without a window where the system
10
+ is wrong.
11
+
12
+ ## Invariants
13
+
14
+ | ID | Law (one line) | Class | Gate |
15
+ | --- | --- | --- | --- |
16
+ | EVO-1 | A change is additive first: add nullable, write both, backfill, then constrain. A migration that adds a `NOT NULL` column with no default to a populated table is refused. | constitutional | `manual` |
17
+ | EVO-2 | A rename is add, copy, switch readers, drop — four deploys, not one `ALTER`. The old name stays until nothing reads it, and "nothing reads it" is verified, not assumed. | constitutional | `manual` |
18
+ | EVO-3 | A choice a customer can flip is a policy, not a shape. Model the general case and constrain it with a domain rule; never generate a different schema per plan, per tier or per customer. | constitutional | `manual` |
19
+ | EVO-4 | Dropping a column or table requires the erasure path (`ARC-DAT-3`) to be updated in the same change. Data that vanished from the schema did not vanish from the backups, the read models or the exports. | constitutional | `manual` |
20
+ | EVO-5 | A catalogue seeded from code (`permission`) is owned by the code. The seed is idempotent, drift between code and table fails a gate, and nobody edits those rows by hand. | constitutional | `manual` |
21
+
22
+ ## Why the policy never becomes a shape
23
+
24
+ This is the rule that pays for itself the most, so it is worth the example.
25
+
26
+ A product offers two shapes of account: one organization with a single
27
+ workspace, or one organization with many. The tempting move is to model them
28
+ differently — no `workspace` table at all in the simple case, or
29
+ `organization.workspace_id` instead of `workspace.organization_id`.
30
+
31
+ What it costs the day a customer upgrades:
32
+
33
+ - a data migration per customer, run live, on their data;
34
+ - two code paths for every read that touches a workspace, forever, because both
35
+ shapes exist in production at once;
36
+ - a scope column that means different things in different databases, which is
37
+ the exact condition under which `SCP-2` stops being checkable.
38
+
39
+ Modelled as a policy instead: `workspace` always exists, always carries
40
+ `organization_id`, and the single-workspace account simply has one — created
41
+ with the organization. What the setting changes is a domain rule that refuses
42
+ the second workspace, and whether the application renders a selector. Upgrading
43
+ a customer is a settings change, and the schema never knew there were two
44
+ products.
45
+
46
+ The general form: **if a customer can change it, it is not allowed to be a
47
+ shape.** Shapes change with migrations; policies change with a column value.
48
+
49
+ ## Additive, in four steps
50
+
51
+ ```text
52
+ 1. add workspace.archived_at TIMESTAMPTZ NULL deploy, nothing reads it
53
+ 2. write both writers set it; readers still use the old signal
54
+ 3. backfill one batch, restartable, with a count that proves it finished
55
+ 4. constrain readers switch; the old signal is dropped in a later deploy
56
+ ```
57
+
58
+ Steps 2 and 3 can share a deploy. Steps 1 and 4 cannot: between them there must
59
+ be a moment where both the old and the new shape are true, because a rollback
60
+ lands in that moment.
61
+
62
+ The backfill produces a number, and the number is checked. "It ran" is not
63
+ evidence; `0 rows remaining with archived_at IS NULL AND status = 'archived'`
64
+ is.
65
+
66
+ ## Seeded catalogues
67
+
68
+ `permission` exists in the database so foreign keys can point at it, but the
69
+ list is in the source: it is the same for every customer, it is deployed with
70
+ the code that enforces it, and a row nobody deployed is a permission nobody
71
+ implements.
72
+
73
+ So the seed is idempotent — insert what is missing, update what changed, and
74
+ report what is in the table but not in the code. That last one is the important
75
+ report: a permission that disappeared from the source while roles still grant it
76
+ means people hold access to something that no longer exists, and that is a
77
+ finding rather than a cleanup.
78
+
79
+ ## Never do
80
+
81
+ ```text
82
+ ❌ ALTER TABLE workspace ADD COLUMN kind TEXT NOT NULL fails on any row
83
+ ✅ ADD COLUMN kind TEXT NULL → backfill → SET NOT NULL (EVO-1)
84
+
85
+ ❌ ALTER TABLE user RENAME COLUMN name TO full_name old readers 500
86
+ ✅ add full_name → write both → switch → drop name (EVO-2)
87
+
88
+ ❌ if (plan === 'starter') { /* no workspace table */ } two schemas
89
+ ✅ one schema; a rule refuses the second workspace (EVO-3)
90
+
91
+ ❌ DROP TABLE otp still in exports and backups
92
+ ✅ drop + update the erasure path in the same change (EVO-4)
93
+
94
+ ❌ INSERT INTO permission ... run once, by hand
95
+ ✅ an idempotent seed the deploy runs, and a drift report (EVO-5)
96
+ ```
@@ -0,0 +1,579 @@
1
+ # Model · IAM
2
+
3
+ **Rules defined here:** `IAM-1` … `IAM-14` — the law is the *Invariants*
4
+ table. Generic modelling rules are cited, never restated.
5
+
6
+ ## What it is for
7
+
8
+ Who a person is, which customers they act for, and what they are allowed to do
9
+ there. Every Turystack product is born with this model: the `auth` audience
10
+ proves identity, and every other audience reads its scope and its permissions
11
+ from here. It is the one model the CLI generates literally — a generated
12
+ repository that differs from this file is a bug in one of the two.
13
+
14
+ It answers three questions, and keeping them separate is the whole design.
15
+ *Who are you* is `user`, and it is not scoped to anyone. *Where are you acting*
16
+ is `membership`, which is the only place a person meets an organization. *What
17
+ may you do there* is `role` → `permission`, resolved at sign-in into the session.
18
+
19
+ ## The model
20
+
21
+ ```text
22
+ organization ──┬──< workspace
23
+ kind: ├──< membership ─── user ──┬──< user_social_identity
24
+ CUSTOMER │ │ └──< otp
25
+ PLATFORM └──< invitation
26
+
27
+ role ─┘ both point at the same role
28
+
29
+ └──< role_permission >── permission
30
+ key: <audience>:<res>.<act>
31
+
32
+ role.kind ENVIRONMENT every organization, shipped by the product
33
+ ORGANIZATION one organization, written by the customer
34
+ BACKOFFICE the platform only
35
+ ```
36
+
37
+ Read the nullable foreign keys as sentences, because they are where the model
38
+ does its work:
39
+
40
+ - `membership.workspace_id IS NULL` — this role applies across the whole
41
+ organization.
42
+ - `role.organization_id IS NULL` — the role is not one customer's; `kind` says
43
+ whether it is shipped to every organization or reserved for the platform.
44
+ - `invitation.user_id IS NULL` — nobody has accepted it yet, and the person may
45
+ not exist in `user` at all.
46
+
47
+ ## How to read the tables
48
+
49
+ Every column is listed, including the ones every table shares — a table you have
50
+ to combine with a legend to know its real shape is a table that gets read wrong.
51
+
52
+ The shared ones are: the key `<table>_id` (`TAB-2`), the timestamps
53
+ `created_at` and `updated_at` (`TAB-3`), `deleted_at` where withdrawal is a
54
+ state rather than an erasure (`ARC-DAT-4`), and the audit trio `created_by`,
55
+ `updated_by`, `deleted_by` where a person writes the row (`TAB-9`).
56
+ `@turystack/nestjs-database` stamps `created_by` and `updated_by` from the
57
+ acting principal wherever the columns exist; the domain writes `deleted_by` when
58
+ it withdraws the row. A table that deliberately omits a block says so under it,
59
+ with the reason.
60
+
61
+ Columns are in the order `ENT-5` and `SCH-8` require — **PK → FKs → important →
62
+ less important → booleans → status → timestamps and audit** — so this file, the
63
+ schema and the entity read the same way top to bottom.
64
+
65
+ Every column whose values are listed is `text` with the set closed by a Zod enum
66
+ in the schema, never a database enum type (`TAB-4`). The JSON under each
67
+ table is the row as a repository returns it: camel case, same order, same
68
+ columns.
69
+
70
+ ## The tables
71
+
72
+ ### `user`
73
+
74
+ The person. Not scoped to an organization (`SCP-1`, third exception).
75
+
76
+ | Column | Type | Null | Meaning |
77
+ | --- | --- | --- | --- |
78
+ | `user_id` | uuid | no | UUIDv7 (`TAB-2`) |
79
+ | `name` | text | no | |
80
+ | `email` | text | no | Lower-cased by the domain before every write; unique globally (`IAM-10`) |
81
+ | `email_verified_at` | timestamptz | yes | Null means unverified |
82
+ | `phone` | text | yes | E.164, `+` and digits. Null means the person gave none |
83
+ | `phone_verified_at` | timestamptz | yes | Null means unverified — independent of the e-mail (`IAM-11`) |
84
+ | `password_hash` | text | yes | Null means no password: this person signs in socially or by code (`IAM-8`) |
85
+ | `password_changed_at` | timestamptz | yes | A session issued before this instant is refused. Null means a password was never set |
86
+ | `locale` | text | no | BCP 47. The language an OTP, an invitation and a notification are written in |
87
+ | `last_signed_in_at` | timestamptz | yes | Null means never signed in |
88
+ | `created_at` | timestamptz | no | |
89
+ | `updated_at` | timestamptz | no | |
90
+ | `deleted_at` | timestamptz | yes | |
91
+ | `created_by` | text | yes | Null when the person signed themselves up |
92
+ | `updated_by` | text | yes | |
93
+ | `deleted_by` | text | yes | |
94
+
95
+ `unique(email)` · `unique(phone) where phone is not null`
96
+
97
+ ```json
98
+ {
99
+ "userId": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
100
+ "name": "Ana Ribeiro",
101
+ "email": "ana@acme.com",
102
+ "emailVerifiedAt": "2026-09-01T14:22:10.000Z",
103
+ "phone": "+5511998877665",
104
+ "phoneVerifiedAt": null,
105
+ "passwordHash": "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$...",
106
+ "passwordChangedAt": "2026-09-01T14:20:02.000Z",
107
+ "locale": "pt-BR",
108
+ "lastSignedInAt": "2026-09-07T09:41:33.000Z",
109
+ "createdAt": "2026-09-01T14:20:02.000Z",
110
+ "updatedAt": "2026-09-07T09:41:33.000Z",
111
+ "deletedAt": null,
112
+ "createdBy": null,
113
+ "updatedBy": null,
114
+ "deletedBy": null
115
+ }
116
+ ```
117
+
118
+ `createdBy` is null because nobody was authenticated: the person signed
119
+ themselves up. An operator creating a user from the backoffice leaves their id.
120
+
121
+ ### `user_social_identity`
122
+
123
+ How a person proves themselves through a provider.
124
+
125
+ | Column | Type | Null | Meaning |
126
+ | --- | --- | --- | --- |
127
+ | `user_social_identity_id` | uuid | no | UUIDv7 |
128
+ | `user_id` | uuid → `user` | no | Owner (`REL-5`) |
129
+ | `provider` | text | no | `APPLE` · `FACEBOOK` · `GOOGLE` · `MICROSOFT` |
130
+ | `provider_id` | text | no | The subject the provider asserts. Stable across the person changing their e-mail there |
131
+ | `provider_email` | text | yes | As asserted when the link was made. May differ from `user.email`, and is never treated as a verified address |
132
+ | `last_used_at` | timestamptz | yes | Last sign-in through this provider. Null means linked but never used |
133
+ | `created_at` | timestamptz | no | |
134
+ | `updated_at` | timestamptz | no | |
135
+ | `created_by` | text | yes | |
136
+ | `updated_by` | text | yes | |
137
+
138
+ `unique(provider, provider_id)` — one provider account maps to one
139
+ person.
140
+
141
+ **No soft delete.** Unlinking a provider removes the row, because a kept row
142
+ would still hold `unique(provider, provider_id)` and refuse the re-link.
143
+ A deliberate hard delete (`ARC-DAT-4`).
144
+
145
+ ```json
146
+ {
147
+ "userSocialIdentityId": "01930f4e-7a02-7b55-8e31-9d4c0f2a6b11",
148
+ "userId": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
149
+ "provider": "GOOGLE",
150
+ "providerId": "104729183746501928374",
151
+ "providerEmail": "ana@acme.com",
152
+ "lastUsedAt": "2026-09-07T09:41:33.000Z",
153
+ "createdAt": "2026-09-03T11:02:47.000Z",
154
+ "updatedAt": "2026-09-07T09:41:33.000Z",
155
+ "createdBy": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
156
+ "updatedBy": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00"
157
+ }
158
+ ```
159
+
160
+ ### `otp`
161
+
162
+ A one-time code. Single-use, bounded, and purposeful.
163
+
164
+ | Column | Type | Null | Meaning |
165
+ | --- | --- | --- | --- |
166
+ | `otp_id` | uuid | no | UUIDv7 |
167
+ | `user_id` | uuid → `user` | no | Owner |
168
+ | `purpose` | text | no | `EMAIL_VERIFICATION` · `PASSWORD_RESET` · `SIGN_IN` |
169
+ | `channel` | text | no | `EMAIL` · `SMS` — which contact carried it |
170
+ | `target` | text | no | The address or number it was sent to, frozen at issue (`IAM-12`) |
171
+ | `code_hash` | text | no | Hashed (`TAB-8`) |
172
+ | `expires_at` | timestamptz | no | |
173
+ | `consumed_at` | timestamptz | yes | Null means still usable (`IAM-6`) |
174
+ | `attempts` | integer | no | Failed verifications, default `0`, bounded by the domain |
175
+ | `created_at` | timestamptz | no | |
176
+ | `updated_at` | timestamptz | no | |
177
+
178
+ Index on `(user_id, purpose)` where `consumed_at is null` — the lookup every
179
+ verification performs.
180
+
181
+ **No audit** — the actor is the `user_id` already on the row. **No soft delete**
182
+ — a spent code is pruned by retention (`ARC-DAT-1`).
183
+
184
+ ```json
185
+ {
186
+ "otpId": "01930f50-1c88-7d09-b2a7-5e6f7a8b9c00",
187
+ "userId": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
188
+ "purpose": "SIGN_IN",
189
+ "channel": "EMAIL",
190
+ "target": "ana@acme.com",
191
+ "codeHash": "$argon2id$v=19$m=19456,t=2,p=1$b3Rwc2FsdA$...",
192
+ "expiresAt": "2026-09-07T09:51:00.000Z",
193
+ "consumedAt": null,
194
+ "attempts": 0,
195
+ "createdAt": "2026-09-07T09:41:00.000Z",
196
+ "updatedAt": "2026-09-07T09:41:00.000Z"
197
+ }
198
+ ```
199
+
200
+ ### `organization`
201
+
202
+ The tenant. The root of every scope in the product.
203
+
204
+ | Column | Type | Null | Meaning |
205
+ | --- | --- | --- | --- |
206
+ | `organization_id` | uuid | no | UUIDv7 |
207
+ | `kind` | text | no | `CUSTOMER` · `PLATFORM` (`IAM-2`) |
208
+ | `name` | text | no | What a person sees |
209
+ | `slug` | text | no | Unique globally — it is the top of the scope tree, so there is nothing to scope it by |
210
+ | `workspace_mode` | text | no | `SINGLE` · `MULTI` (`IAM-9`) |
211
+ | `status` | text | no | `ACTIVE` · `SUSPENDED`. A suspended organization still authenticates its people and resolves no scope for them |
212
+ | `created_at` | timestamptz | no | |
213
+ | `updated_at` | timestamptz | no | |
214
+ | `deleted_at` | timestamptz | yes | |
215
+ | `created_by` | text | yes | Null for the platform organization and for a self-service sign-up |
216
+ | `updated_by` | text | yes | |
217
+ | `deleted_by` | text | yes | |
218
+
219
+ `unique(slug)`
220
+
221
+ ```json
222
+ {
223
+ "organizationId": "01930f4a-3d10-7f42-a81b-6c2e9d5f4a00",
224
+ "kind": "CUSTOMER",
225
+ "name": "Acme Viagens",
226
+ "slug": "acme-viagens",
227
+ "workspaceMode": "MULTI",
228
+ "status": "ACTIVE",
229
+ "createdAt": "2026-08-12T10:00:00.000Z",
230
+ "updatedAt": "2026-09-01T14:20:02.000Z",
231
+ "deletedAt": null,
232
+ "createdBy": null,
233
+ "updatedBy": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
234
+ "deletedBy": null
235
+ }
236
+ ```
237
+
238
+ ### `workspace`
239
+
240
+ A division inside an organization. Present in both workspace modes.
241
+
242
+ | Column | Type | Null | Meaning |
243
+ | --- | --- | --- | --- |
244
+ | `workspace_id` | uuid | no | UUIDv7 |
245
+ | `organization_id` | uuid → `organization` | no | Owner (`REL-5`) |
246
+ | `name` | text | no | |
247
+ | `slug` | text | no | Unique within the organization (`SCP-5`) |
248
+ | `is_default` | boolean | no | Where a session lands when the person picked no workspace. Exactly one per organization |
249
+ | `created_at` | timestamptz | no | |
250
+ | `updated_at` | timestamptz | no | |
251
+ | `deleted_at` | timestamptz | yes | |
252
+ | `created_by` | text | yes | Null for the workspace created with the organization |
253
+ | `updated_by` | text | yes | |
254
+ | `deleted_by` | text | yes | |
255
+
256
+ `unique(organization_id, slug)` · `unique(organization_id) where is_default`
257
+
258
+ ```json
259
+ {
260
+ "workspaceId": "01930f4b-8e77-7a13-9c40-1f5b2d6e3a00",
261
+ "organizationId": "01930f4a-3d10-7f42-a81b-6c2e9d5f4a00",
262
+ "name": "Operações",
263
+ "slug": "operacoes",
264
+ "isDefault": true,
265
+ "createdAt": "2026-08-12T10:00:00.000Z",
266
+ "updatedAt": "2026-08-12T10:00:00.000Z",
267
+ "deletedAt": null,
268
+ "createdBy": null,
269
+ "updatedBy": null,
270
+ "deletedBy": null
271
+ }
272
+ ```
273
+
274
+ ### `membership`
275
+
276
+ A person, in an organization, holding a role. The only table that joins a person
277
+ to a tenant, and it exists only for someone who has accepted (`IAM-13`).
278
+
279
+ | Column | Type | Null | Meaning |
280
+ | --- | --- | --- | --- |
281
+ | `membership_id` | uuid | no | UUIDv7 |
282
+ | `user_id` | uuid → `user` | no | |
283
+ | `organization_id` | uuid → `organization` | **no** | `IAM-1` |
284
+ | `workspace_id` | uuid → `workspace` | **yes** | Null means the whole organization |
285
+ | `role_id` | uuid → `role` | no | `ON DELETE RESTRICT` (`REL-5`) |
286
+ | `status` | text | no | `ACTIVE` · `SUSPENDED` |
287
+ | `created_at` | timestamptz | no | The moment the invitation was accepted |
288
+ | `updated_at` | timestamptz | no | |
289
+ | `deleted_at` | timestamptz | yes | |
290
+ | `created_by` | text | yes | The person who sent the invitation |
291
+ | `updated_by` | text | yes | |
292
+ | `deleted_by` | text | yes | |
293
+
294
+ `unique(user_id, organization_id, workspace_id)` — one role per person per scope.
295
+
296
+ ```json
297
+ {
298
+ "membershipId": "01930f4c-2b90-7c81-84d2-3a7e1c9f5b00",
299
+ "userId": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
300
+ "organizationId": "01930f4a-3d10-7f42-a81b-6c2e9d5f4a00",
301
+ "workspaceId": null,
302
+ "roleId": "01930f49-1a55-7e20-b6f3-8d2c4e7a1b00",
303
+ "status": "ACTIVE",
304
+ "createdAt": "2026-09-01T14:25:40.000Z",
305
+ "updatedAt": "2026-09-01T14:25:40.000Z",
306
+ "deletedAt": null,
307
+ "createdBy": "01930f4d-5511-7e02-9b83-4c7a2f1e6d00",
308
+ "updatedBy": null,
309
+ "deletedBy": null
310
+ }
311
+ ```
312
+
313
+ `workspaceId` is null, so this role applies across the whole organization.
314
+
315
+ ### `invitation`
316
+
317
+ An offer of access, addressed to an e-mail that may not have a `user` row yet
318
+ (`IAM-13`).
319
+
320
+ | Column | Type | Null | Meaning |
321
+ | --- | --- | --- | --- |
322
+ | `invitation_id` | uuid | no | UUIDv7 |
323
+ | `organization_id` | uuid → `organization` | no | The organization being joined |
324
+ | `workspace_id` | uuid → `workspace` | yes | Null means the membership will cover the whole organization |
325
+ | `role_id` | uuid → `role` | no | The role the membership will carry. `ON DELETE RESTRICT` |
326
+ | `user_id` | uuid → `user` | yes | Null until accepted; then the person who accepted (`IAM-14`) |
327
+ | `email` | text | no | Where the offer was sent, lower-cased. Not a foreign key — the person may not exist yet |
328
+ | `token_hash` | text | no | Hashed (`TAB-8`). The plaintext exists only in the link |
329
+ | `expires_at` | timestamptz | no | |
330
+ | `accepted_at` | timestamptz | yes | Null until accepted |
331
+ | `revoked_at` | timestamptz | yes | Null unless withdrawn before acceptance |
332
+ | `status` | text | no | `PENDING` · `ACCEPTED` · `REVOKED` · `EXPIRED` |
333
+ | `created_at` | timestamptz | no | |
334
+ | `updated_at` | timestamptz | no | |
335
+ | `deleted_at` | timestamptz | yes | |
336
+ | `created_by` | text | yes | The person who sent the offer |
337
+ | `updated_by` | text | yes | The person who accepted or revoked it |
338
+ | `deleted_by` | text | yes | |
339
+
340
+ `unique(organization_id, email) where status = 'PENDING'` — one open offer per
341
+ address per organization.
342
+
343
+ ```json
344
+ {
345
+ "invitationId": "01930f4d-c3a1-7f88-9210-6b4e8d1c2f00",
346
+ "organizationId": "01930f4a-3d10-7f42-a81b-6c2e9d5f4a00",
347
+ "workspaceId": null,
348
+ "roleId": "01930f49-1a55-7e20-b6f3-8d2c4e7a1b00",
349
+ "userId": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
350
+ "email": "ana@acme.com",
351
+ "tokenHash": "$argon2id$v=19$m=19456,t=2,p=1$aW52aXRlc2FsdA$...",
352
+ "expiresAt": "2026-09-08T14:20:02.000Z",
353
+ "acceptedAt": "2026-09-01T14:25:40.000Z",
354
+ "revokedAt": null,
355
+ "status": "ACCEPTED",
356
+ "createdAt": "2026-09-01T14:20:02.000Z",
357
+ "updatedAt": "2026-09-01T14:25:40.000Z",
358
+ "deletedAt": null,
359
+ "createdBy": "01930f4d-5511-7e02-9b83-4c7a2f1e6d00",
360
+ "updatedBy": "01930f4e-6b21-7c3a-9f10-2c1a5b7d4e00",
361
+ "deletedBy": null
362
+ }
363
+ ```
364
+
365
+ ### `role`
366
+
367
+ A named bundle of permissions. `kind` says who may hold it.
368
+
369
+ | Column | Type | Null | Meaning |
370
+ | --- | --- | --- | --- |
371
+ | `role_id` | uuid | no | UUIDv7 |
372
+ | `organization_id` | uuid → `organization` | **yes** | Set only when `kind = 'ORGANIZATION'` (`IAM-4`) |
373
+ | `kind` | text | no | `ENVIRONMENT` · `ORGANIZATION` · `BACKOFFICE` |
374
+ | `key` | text | no | `OWNER`, `ADMIN`, `MEMBER`, `OPERATOR`, or whatever a customer names theirs |
375
+ | `name` | text | no | What a person sees |
376
+ | `description` | text | yes | Shown in the role editor |
377
+ | `created_at` | timestamptz | no | |
378
+ | `updated_at` | timestamptz | no | |
379
+ | `deleted_at` | timestamptz | yes | |
380
+ | `created_by` | text | yes | Null for a seeded role |
381
+ | `updated_by` | text | yes | |
382
+ | `deleted_by` | text | yes | |
383
+
384
+ `unique(organization_id, key)`
385
+
386
+ ```json
387
+ {
388
+ "roleId": "01930f49-1a55-7e20-b6f3-8d2c4e7a1b00",
389
+ "organizationId": null,
390
+ "kind": "ENVIRONMENT",
391
+ "key": "OWNER",
392
+ "name": "Owner",
393
+ "description": "Full access to the organization, including billing and members.",
394
+ "createdAt": "2026-08-01T00:00:00.000Z",
395
+ "updatedAt": "2026-08-01T00:00:00.000Z",
396
+ "deletedAt": null,
397
+ "createdBy": null,
398
+ "updatedBy": null,
399
+ "deletedBy": null
400
+ }
401
+ ```
402
+
403
+ `kind` is `ENVIRONMENT`, so `organizationId` is null and every organization can
404
+ hand this role out. `createdBy` is null: the row came from the seed.
405
+
406
+ ### `permission`
407
+
408
+ The catalogue. Seeded from code, never edited by hand (`EVO-5`,
409
+ `ARC-SEC-12`).
410
+
411
+ | Column | Type | Null | Meaning |
412
+ | --- | --- | --- | --- |
413
+ | `permission_id` | uuid | no | UUIDv7 |
414
+ | `key` | text | no | `<audience>:<resource>.<action>` — unique globally (`IAM-5`) |
415
+ | `audience` | text | no | `AUTH` · `ADMIN` · `BACKOFFICE` |
416
+ | `description` | text | no | Shown in the role editor. Grouping parses the key rather than storing the resource a second time |
417
+ | `created_at` | timestamptz | no | |
418
+ | `updated_at` | timestamptz | no | |
419
+
420
+ `unique(key)`
421
+
422
+ **No audit** and **no soft delete.** Nobody writes these rows; a permission that
423
+ disappears from the source is a drift report, not a withdrawal (`EVO-5`).
424
+
425
+ ```json
426
+ {
427
+ "permissionId": "01930f48-0c31-7b10-9a22-4b6d8e1f2a00",
428
+ "key": "admin:workspace.create",
429
+ "audience": "ADMIN",
430
+ "description": "Create a workspace inside the organization.",
431
+ "createdAt": "2026-08-01T00:00:00.000Z",
432
+ "updatedAt": "2026-08-01T00:00:00.000Z"
433
+ }
434
+ ```
435
+
436
+ ### `role_permission`
437
+
438
+ | Column | Type | Null | Meaning |
439
+ | --- | --- | --- | --- |
440
+ | `role_permission_id` | uuid | no | UUIDv7 |
441
+ | `role_id` | uuid → `role` | no | |
442
+ | `permission_id` | uuid → `permission` | no | |
443
+ | `created_at` | timestamptz | no | |
444
+ | `updated_at` | timestamptz | no | |
445
+ | `created_by` | text | yes | Who granted it |
446
+ | `updated_by` | text | yes | |
447
+
448
+ `unique(role_id, permission_id)` (`REL-3`)
449
+
450
+ **No soft delete.** Revoking removes the row, and `created_by` is the record of
451
+ who granted it.
452
+
453
+ ```json
454
+ {
455
+ "rolePermissionId": "01930f48-9f04-7d66-b013-7c2a5e8d4f00",
456
+ "roleId": "01930f49-1a55-7e20-b6f3-8d2c4e7a1b00",
457
+ "permissionId": "01930f48-0c31-7b10-9a22-4b6d8e1f2a00",
458
+ "createdAt": "2026-08-01T00:00:00.000Z",
459
+ "updatedAt": "2026-08-01T00:00:00.000Z",
460
+ "createdBy": null,
461
+ "updatedBy": null
462
+ }
463
+ ```
464
+
465
+ ## Invariants
466
+
467
+ | ID | Law (one line) | Class | Gate |
468
+ | --- | --- | --- | --- |
469
+ | IAM-1 | `membership.organization_id` is NOT NULL. There is no membership outside an organization; the nullable column is `workspace_id`, and its null means the whole organization. | constitutional | `manual` |
470
+ | IAM-2 | The platform operator is a member of the organization whose `kind` is `PLATFORM`. Exactly one such organization exists, it is created by the seed, and it is never created through the product. | constitutional | `manual` |
471
+ | IAM-3 | A permission in the `backoffice:` namespace attaches only to a role whose `kind` is `BACKOFFICE`, and a `BACKOFFICE` role is granted only inside the platform organization. Enforced at seed time and by a domain rule on role editing. | constitutional | `manual` |
472
+ | IAM-4 | `role.kind` decides who may hold the role, and `organization_id` follows from it: `ORGANIZATION` requires one, `ENVIRONMENT` and `BACKOFFICE` forbid one. A customer edits only its own `ORGANIZATION` roles. | constitutional | `manual` |
473
+ | IAM-5 | A permission key is `<audience>:<resource>.<action>`. The prefix is what keeps an admin permission from ever satisfying a backoffice check, and it is part of the key rather than a separate column used for filtering. | constitutional | `manual` |
474
+ | IAM-6 | An OTP is consumed once: `consumed_at` is set in the same transaction that accepts it, and a consumed or expired code is indistinguishable from a wrong one in the response. Attempts are bounded. | constitutional | `manual` |
475
+ | IAM-7 | Sign-in identifies the person, not the tenant. Choosing the organization is a second step that produces the session scope; a person with memberships in three organizations signs in once and picks. | constitutional | `manual` |
476
+ | IAM-8 | `password_hash` is null for a person who has no password. It is never a placeholder, a random value, or the hash of an empty string. | constitutional | `manual` |
477
+ | IAM-9 | `workspace_mode` is configuration on the organization, never a difference in schema and never a difference in the build (`EVO-3`). Both modes have a `workspace` row; `SINGLE` refuses the second, and the selector is present in every build and hidden at runtime — one bundle serves both kinds of customer. | constitutional | `manual` |
478
+ | IAM-10 | `user.email` is unique globally — a deliberate exception to `SCP-5`, because sign-in happens before any organization is known. | constitutional | `manual` |
479
+ | IAM-11 | E-mail and phone are verified independently. Verifying one never sets the other's `*_verified_at`, and a code delivered to a channel proves that channel and nothing else. | constitutional | `manual` |
480
+ | IAM-12 | `otp.channel` and `otp.target` are frozen at issue. Changing the person's e-mail or phone afterwards does not retarget a code already sent, and a code is accepted only for the target it went to. | constitutional | `manual` |
481
+ | IAM-13 | An invitation is its own table, addressed to an e-mail rather than to a user. A `membership` row exists only for someone who accepted, so the member list never shows access that nobody claimed. | constitutional | `manual` |
482
+ | IAM-14 | Accepting an invitation writes the `membership` and stamps `accepted_at`, `user_id` and `ACCEPTED` on the invitation, in one transaction. The invitation is never deleted on acceptance: it is the record of who offered the access and when. | constitutional | `manual` |
483
+
484
+ ## Why the operator is a member of an organization
485
+
486
+ The alternative — `membership.organization_id` nullable, with null meaning
487
+ "platform" — was considered and rejected. It buys a row and costs the invariant
488
+ that makes scoping checkable: with a nullable tenant, *every* membership query
489
+ has to handle a case where there is no tenant, and the compiler cannot tell the
490
+ two apart.
491
+
492
+ Making the platform an organization keeps one shape. Sign-in is one flow. Roles
493
+ and permissions are one machine. The operator's own internal teams are
494
+ workspaces, with no new code. And `SCP-1` stays literally true: every row
495
+ belongs to exactly one organization.
496
+
497
+ What that costs is a real risk, and `IAM-3` is the guard: if a customer
498
+ organization could hold a role with `backoffice:organization.list`, a customer
499
+ would read every other customer. `role.kind` is what makes that guard cheap to
500
+ check — the question is one column, not a walk through the role's permissions.
501
+
502
+ ## Why the role has a kind
503
+
504
+ Three kinds of role exist in any product with a backoffice, and encoding them as
505
+ "organization_id is null or not" collapses two of them into one:
506
+
507
+ - **`ENVIRONMENT`** — shipped by the product, available to every organization.
508
+ `OWNER`, `ADMIN`, `MEMBER`. A customer hands them out and cannot edit them.
509
+ - **`ORGANIZATION`** — written by one customer, for that customer.
510
+ - **`BACKOFFICE`** — the platform's own. `OPERATOR`. This is the only kind that
511
+ may hold a `backoffice:` permission.
512
+
513
+ Both `ENVIRONMENT` and `BACKOFFICE` have a null `organization_id`, so the null
514
+ alone cannot tell them apart — and the difference is exactly the one that
515
+ matters, because one is offered to every customer and the other must never be.
516
+ `kind` states it, and `IAM-4` binds the null to it.
517
+
518
+ ## Why the invitation is its own table
519
+
520
+ An invitation is addressed to an **e-mail**, not to a person. The address may
521
+ belong to nobody yet, so modelling the offer as a `membership` in a pending
522
+ state would require inventing a `user` row for someone who has not agreed to
523
+ exist — a half-person that sign-in, the member list and erasure all have to know
524
+ about.
525
+
526
+ Separating them also separates two lifetimes that genuinely differ. An
527
+ invitation expires, is revoked, is re-sent. A membership does none of those: it
528
+ is active or suspended. Putting both in one row means a status enum that mixes
529
+ "has not answered" with "no longer allowed", and every read has to know which
530
+ half it is looking at.
531
+
532
+ What the two share is the destination — organization, workspace, role — and that
533
+ is why `invitation` carries the same three foreign keys the membership will get.
534
+ Acceptance copies them, and `IAM-14` makes that one transaction.
535
+
536
+ ## Sign-in produces a scope
537
+
538
+ ```text
539
+ 1. prove identity password · social · code → the person
540
+ 2. list memberships organizations they belong to → one, or a choice
541
+ 3. resolve permissions role → role_permission → keys → for that scope
542
+ 4. issue the session user_id, organization_id, workspace_id, permissions[]
543
+ ```
544
+
545
+ Step 4 is what every other audience reads (`ARC-SEC-1`): the scope on the
546
+ session is the only scope the product trusts, and no endpoint accepts an
547
+ organization from the client.
548
+
549
+ A person with one membership never sees step 2 — but the model does not know
550
+ that, and the API is the same either way. That is why step 2 is a step and not a
551
+ special case.
552
+
553
+ ## The three audiences
554
+
555
+ | Audience | App | Scope | Permission namespace |
556
+ | --- | --- | --- | --- |
557
+ | `auth` | `apps/auth` | none — identity only | `auth:` |
558
+ | `admin` | `apps/admin` | one organization, from the session | `admin:` |
559
+ | `backoffice` | `apps/backoffice` | across organizations | `backoffice:` |
560
+
561
+ `apps/admin` holds no authentication code: it mounts `<AuthProvider>` and reads
562
+ the session. `apps/backoffice` is a separate application rather than a route
563
+ inside admin, because a cross-tenant read must not share a bundle, a route tree
564
+ or a session shape with a tenant-scoped one.
565
+
566
+ ## What the CLI generates
567
+
568
+ - `domains/iam` — the ten tables above, their repositories and the use cases for
569
+ sign-up, sign-in (password, social, code), OTP issue and consume, organization
570
+ and workspace creation, invitation send, revoke and accept, and role
571
+ management.
572
+ - The seed: the platform organization, the `ENVIRONMENT` roles `OWNER`, `ADMIN`
573
+ and `MEMBER`, the `BACKOFFICE` role `OPERATOR`, and the permission catalogue
574
+ derived from the code (`EVO-5`).
575
+ - `apps/auth`, `apps/admin`, `apps/backoffice`, and the three audiences on the
576
+ API.
577
+ - The workspace selector in the admin sidebar, always generated and rendered
578
+ only when the signed-in organization's `workspace_mode` is `MULTI` — and the
579
+ domain rule that refuses a second workspace when it is `SINGLE`.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @turystack/modeling
2
+
3
+ The modelling skill: what becomes a table, how tables relate, how every row is
4
+ scoped to a customer, and how a model changes once it has rows in it — plus the
5
+ library of canonical models the CLI generates literally.
6
+
7
+ | File | Owns |
8
+ | --- | --- |
9
+ | [`SKILL.md`](SKILL.md) | Entry point and routing |
10
+ | [`00-overview.md`](00-overview.md) | Mental model, the five costly mistakes, how a model file is written |
11
+ | [`01-entities.md`](01-entities.md) | `TAB` — tables, identity, keys, naming, types |
12
+ | [`02-relationships.md`](02-relationships.md) | `REL` — cardinality, join tables, optional keys, ownership |
13
+ | [`03-scoping.md`](03-scoping.md) | `SCP` — tenancy columns, scoped uniqueness, cross-tenant reads |
14
+ | [`04-evolution.md`](04-evolution.md) | `EVO` — additive change, renames, policy vs shape, seeds |
15
+ | [`10-model-iam.md`](10-model-iam.md) | `IAM` — identity and access: organization, workspace, user, membership, role, permission |
16
+
17
+ Models are numbered from `10`. A new canonical model takes the next number and
18
+ its own `<NAME>` namespace.
19
+
20
+ Installed into `.claude/skills` and/or `.codex/skills` by the Turystack CLI.
package/SKILL.md ADDED
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: turystack-modeling
3
+ description: "How a Turystack product is modelled — what becomes a table, how tables relate, how every row is scoped to a customer, and how a model changes without breaking the repositories already running on it. Open it before writing a migration, adding an entity, changing a cardinality, or designing a new domain; and open the canonical model file when the domain is one Turystack already ships, because those models are law rather than suggestion. It owns entity identity and naming, cardinality and join tables, optional relationships and what a null means, ownership and cascade, tenancy columns and scoped uniqueness, additive evolution, and the model library — IAM first: organization, workspace, user, membership, role, permission. Code mechanics live in turystack-backend-pattern; boundaries, consistency and data lifetime live in turystack-architecture-pattern."
4
+ ---
5
+
6
+ # turystack-modeling
7
+
8
+ What exists, and how it relates. Read it before a migration or a new domain.
9
+
10
+ ## What lives here, and what does not
11
+
12
+ A law belongs here when it is about **shape**: whether something is a table or a
13
+ column, which side holds the foreign key, what a null means, which column
14
+ carries the tenant, how uniqueness is scoped, and how that shape is allowed to
15
+ change.
16
+
17
+ A law about **boundaries, consistency, failure or lifetime** belongs to
18
+ `turystack-architecture-pattern`. A rule about **how the code is written** —
19
+ Drizzle syntax, repository methods, decorators — belongs to
20
+ `turystack-backend-pattern`.
21
+
22
+ ```text
23
+ turystack-architecture-pattern boundaries, transactions, lifetime, security
24
+ └── turystack-modeling ← this: the shape of the data
25
+ └── turystack-backend-pattern how that shape is written in this stack
26
+ ```
27
+
28
+ **One law, one owner.** Data retention and erasure are `ARC-DAT-*`. Scope coming
29
+ from the authenticated context is `ARC-SEC-1`. The permission catalogue is
30
+ `ARC-SEC-12`. This skill cites those ids and never restates them; what it adds
31
+ is the shape they imply.
32
+
33
+ ## How to use
34
+
35
+ 1. Read `00-overview.md`. It carries the mental model and the five mistakes that
36
+ cost the most, and it is short enough to keep in context.
37
+ 2. Open the law section your change touches. Read the whole section rather than
38
+ a remembered summary — these rules are dense.
39
+ 3. If the domain is one Turystack already models, open its model file (`1x-`)
40
+ and follow it exactly. Those models are not examples; the CLI generates them
41
+ literally, and a project that diverges from one has to say why in writing.
42
+ 4. Then read `turystack-backend-pattern` for how to write it here.
43
+
44
+ ## Routing
45
+
46
+ | Your change | Read |
47
+ | --- | --- |
48
+ | A new table, or deciding whether something *is* a table | `01-entities.md` |
49
+ | A foreign key, a join table, an optional relationship, a cascade | `02-relationships.md` |
50
+ | Anything a customer owns, any uniqueness rule, any cross-customer read | `03-scoping.md` |
51
+ | A migration on a model that already has rows, or flipping a cardinality | `04-evolution.md` |
52
+ | Sign-in, organizations, workspaces, roles, permissions, OTP | `10-model-iam.md` |
53
+
54
+ ## How a section is written
55
+
56
+ - **Concept** — what the section governs.
57
+ - **Invariants** — the law, one row per rule, each with a stable id
58
+ `<NAMESPACE>-n`. **This table is what a review binds to.**
59
+ - Prose explaining why the non-obvious rules exist, because a rule you
60
+ understand survives a refactor and a rule you memorised does not.
61
+ - **Never do** — the same law as violations, so the shape is recognisable in a
62
+ diff.
63
+
64
+ A model file (`1x-`) adds two things: the **tables**, column by column, and the
65
+ **invariants that are true of this model specifically** — the ones a generic
66
+ modelling rule cannot express, like "a role carrying a `backoffice:` permission
67
+ attaches only to the platform organization".
68
+
69
+ Ids are stable across versions. Every section opens with a **Rules defined
70
+ here** line naming the ids it owns.
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@turystack/modeling",
3
+ "description": "Data modelling skill — what becomes a table, how tables relate, how every row is scoped to a customer, how a model evolves once it has rows, and the library of canonical models the CLI generates: IAM first. Installed into .claude/skills and/or .codex/skills via the turystack CLI.",
4
+ "private": false,
5
+ "version": "1.0.1",
6
+ "packageManager": "pnpm@10.12.3",
7
+ "sideEffects": false,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "engines": {
12
+ "node": ">=20"
13
+ },
14
+ "files": [
15
+ "*.md"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/turystack/modeling-skill.git"
20
+ }
21
+ }