@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 +96 -0
- package/01-entities.md +147 -0
- package/02-relationships.md +86 -0
- package/03-scoping.md +141 -0
- package/04-evolution.md +96 -0
- package/10-model-iam.md +579 -0
- package/README.md +20 -0
- package/SKILL.md +70 -0
- package/package.json +21 -0
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
|
+
```
|
package/04-evolution.md
ADDED
|
@@ -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
|
+
```
|
package/10-model-iam.md
ADDED
|
@@ -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
|
+
}
|