@kindgi/platform 0.1.3 → 0.1.4-rc.0

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.
@@ -15,16 +15,21 @@
15
15
  * Both factories are returned together so the two adapters share the
16
16
  * same team map (a membership add must fail cleanly for a
17
17
  * non-existent team; splitting into two disjoint factories would
18
- * require an extra cross-binding contract).
18
+ * require an extra cross-binding contract). Team slugs are unique
19
+ * within a tenant.
19
20
  */
20
21
 
21
22
  import type { Filter, Page, TeamId, TenantId, UserId } from '@kindgi/types';
22
23
 
23
24
  import type {
24
25
  TeamBinding,
26
+ TeamCreateOutcome,
25
27
  TeamListFilter,
26
28
  TeamMembershipAddInput,
29
+ TeamMembershipAddOutcome,
27
30
  TeamMembershipBinding,
31
+ TeamMembershipUpdateRoleOutcome,
32
+ TeamUpdateOutcome,
28
33
  } from '../team-binding.js';
29
34
  import type { Team, TeamMembership, TeamPatch, TeamRole, TeamSpec } from '../types.js';
30
35
 
@@ -59,8 +64,17 @@ export function makeInMemoryTeamBinding(): {
59
64
  return t;
60
65
  }
61
66
 
67
+ /** Is `slug` held by a team in the tenant other than `exceptId`? */
68
+ function slugTaken(tenantId: TenantId, slug: string, exceptId?: TeamId): boolean {
69
+ for (const row of teamRows.values()) {
70
+ if (row.tenantId === tenantId && row.slug === slug && row.id !== exceptId) return true;
71
+ }
72
+ return false;
73
+ }
74
+
62
75
  const teams: TeamBinding = {
63
- async create(tenantId, spec: TeamSpec): Promise<TeamId> {
76
+ async create(tenantId, spec: TeamSpec): Promise<TeamCreateOutcome> {
77
+ if (slugTaken(tenantId, spec.slug)) return { kind: 'slug-conflict', slug: spec.slug };
64
78
  const id = nextTeamId();
65
79
  const now = nowTimestamp();
66
80
  const row: Team = {
@@ -74,7 +88,7 @@ export function makeInMemoryTeamBinding(): {
74
88
  ...(spec.description !== undefined ? { description: spec.description } : {}),
75
89
  };
76
90
  teamRows.set(id, row);
77
- return id;
91
+ return { kind: 'ok', teamId: id };
78
92
  },
79
93
 
80
94
  async get(tenantId, teamId): Promise<Team | undefined> {
@@ -98,10 +112,13 @@ export function makeInMemoryTeamBinding(): {
98
112
  return paginate(all, filter.limit, filter.cursor);
99
113
  },
100
114
 
101
- async update(tenantId, teamId, patch: TeamPatch): Promise<void> {
115
+ async update(tenantId, teamId, patch: TeamPatch): Promise<TeamUpdateOutcome> {
102
116
  const row = findTeamInTenant(tenantId, teamId);
103
117
  if (row === undefined) {
104
- throw new Error(`team-not-found: ${teamId}`);
118
+ return { kind: 'team-not-found' };
119
+ }
120
+ if (patch.slug !== undefined && slugTaken(tenantId, patch.slug, teamId)) {
121
+ return { kind: 'slug-conflict', slug: patch.slug };
105
122
  }
106
123
  // orgId patch tri-state: undefined = untouched; null = clear;
107
124
  // OrgId value = set.
@@ -121,6 +138,7 @@ export function makeInMemoryTeamBinding(): {
121
138
  ...(nextDescription !== undefined ? { description: nextDescription } : {}),
122
139
  };
123
140
  teamRows.set(teamId, next);
141
+ return { kind: 'ok' };
124
142
  },
125
143
 
126
144
  async delete(tenantId, teamId): Promise<void> {
@@ -137,15 +155,15 @@ export function makeInMemoryTeamBinding(): {
137
155
  };
138
156
 
139
157
  const memberships: TeamMembershipBinding = {
140
- async add(tenantId, input: TeamMembershipAddInput): Promise<void> {
158
+ async add(tenantId, input: TeamMembershipAddInput): Promise<TeamMembershipAddOutcome> {
141
159
  const team = findTeamInTenant(tenantId, input.teamId);
142
160
  if (team === undefined) {
143
- throw new Error(`team-not-found: ${input.teamId}`);
161
+ return { kind: 'team-not-found' };
144
162
  }
145
163
  const key = membershipKey(input.teamId, input.userId);
146
164
  if (membershipRows.has(key)) {
147
165
  // Idempotent — role mutation goes through updateRole.
148
- return;
166
+ return { kind: 'ok' };
149
167
  }
150
168
  const row: TeamMembership = {
151
169
  teamId: input.teamId,
@@ -154,6 +172,7 @@ export function makeInMemoryTeamBinding(): {
154
172
  joinedAt: nowTimestamp(),
155
173
  };
156
174
  membershipRows.set(key, row);
175
+ return { kind: 'ok' };
157
176
  },
158
177
 
159
178
  async remove(tenantId, teamId, userId): Promise<void> {
@@ -187,17 +206,23 @@ export function makeInMemoryTeamBinding(): {
187
206
  return paginate(all, filter.limit, filter.cursor);
188
207
  },
189
208
 
190
- async updateRole(tenantId, teamId, userId, role: TeamRole): Promise<void> {
209
+ async updateRole(
210
+ tenantId,
211
+ teamId,
212
+ userId,
213
+ role: TeamRole,
214
+ ): Promise<TeamMembershipUpdateRoleOutcome> {
191
215
  const team = findTeamInTenant(tenantId, teamId);
192
216
  if (team === undefined) {
193
- throw new Error(`team-not-found: ${teamId}`);
217
+ return { kind: 'team-not-found' };
194
218
  }
195
219
  const key = membershipKey(teamId, userId);
196
220
  const row = membershipRows.get(key);
197
221
  if (row === undefined) {
198
- throw new Error(`team-membership-not-found: ${teamId}/${userId}`);
222
+ return { kind: 'team-membership-not-found' };
199
223
  }
200
224
  membershipRows.set(key, { ...row, role });
225
+ return { kind: 'ok' };
201
226
  },
202
227
  };
203
228
 
package/src/index.ts CHANGED
@@ -15,19 +15,30 @@ export { scopeKey } from './scope.js';
15
15
 
16
16
  export type {
17
17
  OrgBinding,
18
+ OrgCreateOutcome,
18
19
  OrgListFilter,
20
+ OrgDeleteConflict,
21
+ OrgUpdateOutcome,
19
22
  } from './org-binding.js';
20
23
  export type {
21
24
  TeamBinding,
25
+ TeamCreateOutcome,
22
26
  TeamListFilter,
23
27
  TeamMembershipAddInput,
28
+ TeamMembershipAddOutcome,
24
29
  TeamMembershipBinding,
30
+ TeamMembershipUpdateRoleOutcome,
31
+ TeamUpdateOutcome,
25
32
  } from './team-binding.js';
26
33
  export type {
27
34
  ProjectBinding,
35
+ ProjectCreateOutcome,
28
36
  ProjectListFilter,
29
37
  ProjectMembershipAddInput,
38
+ ProjectMembershipAddOutcome,
30
39
  ProjectMembershipBinding,
40
+ ProjectMembershipUpdateRoleOutcome,
41
+ ProjectUpdateOutcome,
31
42
  } from './project-binding.js';
32
43
  export type {
33
44
  TeamProjectGrant,
@@ -42,7 +53,9 @@ export {
42
53
  } from './in-memory/index.js';
43
54
 
44
55
  export type {
56
+ AddProjectMemberError,
45
57
  AddProjectMemberParams,
58
+ AddTeamMemberError,
46
59
  AddTeamMemberParams,
47
60
  CreateOrgError,
48
61
  CreateOrgParams,
@@ -50,7 +63,6 @@ export type {
50
63
  CreateProjectParams,
51
64
  CreateTeamError,
52
65
  CreateTeamParams,
53
- MembershipMutationError,
54
66
  TenantHierarchyBinding,
55
67
  TenantSummary,
56
68
  } from './tenant-hierarchy-binding.js';
@@ -12,6 +12,12 @@
12
12
  * `@kindgi/types` (`items` + `nextCursor`).
13
13
  * - `list` filters extend the shared `Filter` shape so every list call
14
14
  * shares the same pagination fields.
15
+ * - A write the caller can get wrong (a slug already taken, a row that
16
+ * doesn't exist) resolves to an outcome — a union discriminated on
17
+ * `kind`, `'ok'` on success — and never rejects for it. Each
18
+ * non-`ok` `kind` is also the API error code the routes answer with
19
+ * (`slug-conflict` is a 409, `org-not-found` a 404). A rejected
20
+ * promise means the backend failed.
15
21
  *
16
22
  * This package ships the interface + reference in-memory adapter
17
23
  * (in `./in-memory/org-binding.ts`). Any adapter can be checked with
@@ -43,11 +49,13 @@ export interface OrgListFilter extends Filter {
43
49
  */
44
50
  export interface OrgBinding {
45
51
  /**
46
- * Create a new `Org` in the given tenant. Returns the freshly-
47
- * assigned `OrgId`. The binding is responsible for allocating IDs,
48
- * timestamps, and slug-uniqueness enforcement within the tenant.
52
+ * Create a new `Org` in the given tenant. Resolves to `ok` with the
53
+ * freshly-assigned `OrgId`, or to `slug-conflict` when another org in
54
+ * the tenant has `spec.slug`. The binding is responsible for
55
+ * allocating IDs, timestamps, and slug-uniqueness enforcement within
56
+ * the tenant.
49
57
  */
50
- create(tenantId: TenantId, spec: OrgSpec): Promise<OrgId>;
58
+ create(tenantId: TenantId, spec: OrgSpec): Promise<OrgCreateOutcome>;
51
59
  /**
52
60
  * Look up an `Org` by id. Returns `undefined` when no row exists in
53
61
  * the given tenant, or when the row exists in a different tenant
@@ -62,15 +70,55 @@ export interface OrgBinding {
62
70
  list(tenantId: TenantId, filter: OrgListFilter): Promise<Page<Org>>;
63
71
  /**
64
72
  * Partially update an `Org`. Fields absent from `patch` are left
65
- * unchanged; fields present are set to the new value. Not-found is
66
- * an error the adapter surfaces (e.g. as a rejected promise); the
67
- * conformance suite specifies the exact shape.
73
+ * unchanged; fields present are set to the new value. Resolves to
74
+ * `org-not-found` when no org has `orgId` in the tenant, and to
75
+ * `slug-conflict` when `patch.slug` is another org's slug.
68
76
  */
69
- update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<void>;
77
+ update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<OrgUpdateOutcome>;
70
78
  /**
71
- * Delete an `Org`. Cascade semantics (dependent teams / projects) are
72
- * a storage-layer concern — the in-memory adapter does the delete
73
- * unconditionally.
79
+ * Delete an `Org`: a tombstone, not an erase. From then on `get`,
80
+ * `list` and `update` treat it as unknown, and its slug is free for a
81
+ * new org; a retention policy on the `org` domain purges the row.
82
+ * What happens to dependents (teams, projects, org-scoped config) is
83
+ * the storage's concern; the in-memory adapter has none. A storage
84
+ * whose projects leave a deleted org (the Kindgi runtime's: they
85
+ * become projects without an org) answers `slug-conflict` instead
86
+ * when one of them has the slug of a project that has no org, and
87
+ * deletes nothing. Nothing (`void`): the org is deleted, or wasn't
88
+ * there (or was already deleted).
74
89
  */
75
- delete(tenantId: TenantId, orgId: OrgId): Promise<void>;
90
+ // biome-ignore lint/suspicious/noConfusingVoidType: `void`, not `undefined`, so a binding whose `delete` returns `Promise<void>` still conforms.
91
+ delete(tenantId: TenantId, orgId: OrgId): Promise<void | OrgDeleteConflict>;
76
92
  }
93
+
94
+ /**
95
+ * Why `OrgBinding.delete` deleted nothing: the org's projects would leave
96
+ * it with `slugs` that projects without an org already have. Rename or
97
+ * move them first.
98
+ */
99
+ export interface OrgDeleteConflict {
100
+ readonly kind: 'slug-conflict';
101
+ readonly slugs: readonly string[];
102
+ }
103
+
104
+ /** What `OrgBinding.create` did. */
105
+ export type OrgCreateOutcome =
106
+ | { readonly kind: 'ok'; readonly orgId: OrgId }
107
+ | {
108
+ /** Another org in the tenant already has this slug. */
109
+ readonly kind: 'slug-conflict';
110
+ readonly slug: string;
111
+ };
112
+
113
+ /** What `OrgBinding.update` did. */
114
+ export type OrgUpdateOutcome =
115
+ | { readonly kind: 'ok' }
116
+ | {
117
+ /** No org with this id in the tenant. */
118
+ readonly kind: 'org-not-found';
119
+ }
120
+ | {
121
+ /** Another org in the tenant already has the patched slug. */
122
+ readonly kind: 'slug-conflict';
123
+ readonly slug: string;
124
+ };
@@ -20,7 +20,9 @@
20
20
  * `AgentPublishInput.projectId` in `@kindgi/api`).
21
21
  *
22
22
  * Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
23
- * return, `Filter`-extending filter shapes.
23
+ * return, `Filter`-extending filter shapes, and writes the caller can
24
+ * get wrong resolve to an outcome discriminated on `kind` instead of
25
+ * rejecting.
24
26
  */
25
27
 
26
28
  import type { Filter, OrgId, Page, ProjectId, TenantId, UserId } from '@kindgi/types';
@@ -58,12 +60,14 @@ export interface ProjectMembershipAddInput {
58
60
  */
59
61
  export interface ProjectBinding {
60
62
  /**
61
- * Create a `Project` within the tenant. `spec.orgId` optional. If
62
- * `spec.isDefault === true` and a Default already exists, the
63
- * binding rejects — the isDefault-uniqueness guardrail is enforced
64
- * at write time.
63
+ * Create a `Project` within the tenant. `spec.orgId` optional.
64
+ * Resolves to `ok` with the assigned `ProjectId`; to `slug-conflict`
65
+ * when another project in the tenant has `spec.slug`; and, when
66
+ * `spec.isDefault === true` and the tenant already has a Default, to
67
+ * `project-default-already-exists` — the isDefault-uniqueness
68
+ * guardrail is enforced at write time.
65
69
  */
66
- create(tenantId: TenantId, spec: ProjectSpec): Promise<ProjectId>;
70
+ create(tenantId: TenantId, spec: ProjectSpec): Promise<ProjectCreateOutcome>;
67
71
  /**
68
72
  * Look up a `Project` by id. `undefined` when unknown or in a
69
73
  * different tenant.
@@ -81,9 +85,15 @@ export interface ProjectBinding {
81
85
  getDefault(tenantId: TenantId): Promise<Project | undefined>;
82
86
  /**
83
87
  * Partially update a `Project`. `ProjectPatch.orgId` supports
84
- * `null` to re-assign the project out of any org.
88
+ * `null` to re-assign the project out of any org. Resolves to
89
+ * `project-not-found` when no project has `projectId` in the tenant,
90
+ * and to `slug-conflict` when `patch.slug` is another project's slug.
85
91
  */
86
- update(tenantId: TenantId, projectId: ProjectId, patch: ProjectPatch): Promise<void>;
92
+ update(
93
+ tenantId: TenantId,
94
+ projectId: ProjectId,
95
+ patch: ProjectPatch,
96
+ ): Promise<ProjectUpdateOutcome>;
87
97
  /**
88
98
  * Delete a `Project`. What happens to content rows that still
89
99
  * reference the project is the storage layer's concern; the
@@ -104,8 +114,10 @@ export interface ProjectMembershipBinding {
104
114
  * Add a user directly to a project. Idempotent on
105
115
  * `(projectId, userId)` — re-adding the same user does not mutate
106
116
  * a stored differing role. Use `updateRole` to change roles.
117
+ * Resolves to `project-not-found` when no project has
118
+ * `input.projectId` in the tenant.
107
119
  */
108
- add(tenantId: TenantId, input: ProjectMembershipAddInput): Promise<void>;
120
+ add(tenantId: TenantId, input: ProjectMembershipAddInput): Promise<ProjectMembershipAddOutcome>;
109
121
  /**
110
122
  * Remove the direct project grant. No-op when absent.
111
123
  */
@@ -122,13 +134,61 @@ export interface ProjectMembershipBinding {
122
134
  */
123
135
  listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<ProjectMembership>>;
124
136
  /**
125
- * Update an existing direct-membership's role. Errors when the
126
- * membership doesn't exist.
137
+ * Update an existing direct-membership's role. Resolves to
138
+ * `project-not-found` when the project isn't in the tenant, and to
139
+ * `project-membership-not-found` when the user has no direct
140
+ * membership.
127
141
  */
128
142
  updateRole(
129
143
  tenantId: TenantId,
130
144
  projectId: ProjectId,
131
145
  userId: UserId,
132
146
  role: ProjectRole,
133
- ): Promise<void>;
147
+ ): Promise<ProjectMembershipUpdateRoleOutcome>;
134
148
  }
149
+
150
+ /** What `ProjectBinding.create` did. */
151
+ export type ProjectCreateOutcome =
152
+ | { readonly kind: 'ok'; readonly projectId: ProjectId }
153
+ | {
154
+ /** Another project in the tenant already has this slug. */
155
+ readonly kind: 'slug-conflict';
156
+ readonly slug: string;
157
+ }
158
+ | {
159
+ /** `spec.isDefault` was `true` and the tenant already has a Default project. */
160
+ readonly kind: 'project-default-already-exists';
161
+ };
162
+
163
+ /** What `ProjectBinding.update` did. */
164
+ export type ProjectUpdateOutcome =
165
+ | { readonly kind: 'ok' }
166
+ | {
167
+ /** No project with this id in the tenant. */
168
+ readonly kind: 'project-not-found';
169
+ }
170
+ | {
171
+ /** Another project in the tenant already has the patched slug. */
172
+ readonly kind: 'slug-conflict';
173
+ readonly slug: string;
174
+ };
175
+
176
+ /** What `ProjectMembershipBinding.add` did (`ok` when already a member, too). */
177
+ export type ProjectMembershipAddOutcome =
178
+ | { readonly kind: 'ok' }
179
+ | {
180
+ /** No project with this id in the tenant. */
181
+ readonly kind: 'project-not-found';
182
+ };
183
+
184
+ /** What `ProjectMembershipBinding.updateRole` did. */
185
+ export type ProjectMembershipUpdateRoleOutcome =
186
+ | { readonly kind: 'ok' }
187
+ | {
188
+ /** No project with this id in the tenant. */
189
+ readonly kind: 'project-not-found';
190
+ }
191
+ | {
192
+ /** The user has no direct membership on the project. */
193
+ readonly kind: 'project-membership-not-found';
194
+ };
@@ -10,7 +10,9 @@
10
10
  * truth.
11
11
  *
12
12
  * Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
13
- * return, `Filter`-extending filter shapes.
13
+ * return, `Filter`-extending filter shapes, and writes the caller can
14
+ * get wrong resolve to an outcome discriminated on `kind` instead of
15
+ * rejecting.
14
16
  */
15
17
 
16
18
  import type { Filter, OrgId, Page, TeamId, TenantId, UserId } from '@kindgi/types';
@@ -46,10 +48,11 @@ export interface TeamMembershipAddInput {
46
48
  export interface TeamBinding {
47
49
  /**
48
50
  * Create a `Team` within the tenant. `spec.orgId` optional (a team
49
- * may be cross-org — nullable FK). Returns the assigned
50
- * `TeamId`.
51
+ * may be cross-org — nullable FK). Resolves to `ok` with the
52
+ * assigned `TeamId`, or to `slug-conflict` when another team in the
53
+ * tenant has `spec.slug`.
51
54
  */
52
- create(tenantId: TenantId, spec: TeamSpec): Promise<TeamId>;
55
+ create(tenantId: TenantId, spec: TeamSpec): Promise<TeamCreateOutcome>;
53
56
  /**
54
57
  * Look up a `Team` by id. `undefined` when unknown or in a different
55
58
  * tenant.
@@ -63,9 +66,11 @@ export interface TeamBinding {
63
66
  /**
64
67
  * Partially update a `Team`. `TeamPatch.orgId` supports `null` to
65
68
  * explicitly re-assign the team out of any org (cross-org), and
66
- * `undefined` to leave the FK untouched.
69
+ * `undefined` to leave the FK untouched. Resolves to `team-not-found`
70
+ * when no team has `teamId` in the tenant, and to `slug-conflict`
71
+ * when `patch.slug` is another team's slug.
67
72
  */
68
- update(tenantId: TenantId, teamId: TeamId, patch: TeamPatch): Promise<void>;
73
+ update(tenantId: TenantId, teamId: TeamId, patch: TeamPatch): Promise<TeamUpdateOutcome>;
69
74
  /**
70
75
  * Delete a `Team`. Its membership rows are removed with it (the
71
76
  * in-memory adapter deletes them from its map).
@@ -88,9 +93,10 @@ export interface TeamMembershipBinding {
88
93
  * silently mutate a differing role).
89
94
  *
90
95
  * The conformance suite pins this: adding an existing member whose
91
- * stored role differs is a no-op (not a role overwrite).
96
+ * stored role differs is a no-op (not a role overwrite). Resolves to
97
+ * `team-not-found` when no team has `input.teamId` in the tenant.
92
98
  */
93
- add(tenantId: TenantId, input: TeamMembershipAddInput): Promise<void>;
99
+ add(tenantId: TenantId, input: TeamMembershipAddInput): Promise<TeamMembershipAddOutcome>;
94
100
  /**
95
101
  * Remove a user from a team. Missing membership is a no-op (not an
96
102
  * error) — matches every peer platform's membership-remove shape.
@@ -108,9 +114,57 @@ export interface TeamMembershipBinding {
108
114
  */
109
115
  listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<TeamMembership>>;
110
116
  /**
111
- * Update an existing membership's role. Errors when the membership
112
- * doesn't exist — role mutation of a non-member is a caller mistake,
113
- * not a silent add. Conformance suite pins the not-found behaviour.
117
+ * Update an existing membership's role. Resolves to `team-not-found`
118
+ * when the team isn't in the tenant, and to
119
+ * `team-membership-not-found` when the user isn't a member — role
120
+ * mutation of a non-member is a caller mistake, not a silent add.
114
121
  */
115
- updateRole(tenantId: TenantId, teamId: TeamId, userId: UserId, role: TeamRole): Promise<void>;
122
+ updateRole(
123
+ tenantId: TenantId,
124
+ teamId: TeamId,
125
+ userId: UserId,
126
+ role: TeamRole,
127
+ ): Promise<TeamMembershipUpdateRoleOutcome>;
116
128
  }
129
+
130
+ /** What `TeamBinding.create` did. */
131
+ export type TeamCreateOutcome =
132
+ | { readonly kind: 'ok'; readonly teamId: TeamId }
133
+ | {
134
+ /** Another team in the tenant already has this slug. */
135
+ readonly kind: 'slug-conflict';
136
+ readonly slug: string;
137
+ };
138
+
139
+ /** What `TeamBinding.update` did. */
140
+ export type TeamUpdateOutcome =
141
+ | { readonly kind: 'ok' }
142
+ | {
143
+ /** No team with this id in the tenant. */
144
+ readonly kind: 'team-not-found';
145
+ }
146
+ | {
147
+ /** Another team in the tenant already has the patched slug. */
148
+ readonly kind: 'slug-conflict';
149
+ readonly slug: string;
150
+ };
151
+
152
+ /** What `TeamMembershipBinding.add` did (`ok` when already a member, too). */
153
+ export type TeamMembershipAddOutcome =
154
+ | { readonly kind: 'ok' }
155
+ | {
156
+ /** No team with this id in the tenant. */
157
+ readonly kind: 'team-not-found';
158
+ };
159
+
160
+ /** What `TeamMembershipBinding.updateRole` did. */
161
+ export type TeamMembershipUpdateRoleOutcome =
162
+ | { readonly kind: 'ok' }
163
+ | {
164
+ /** No team with this id in the tenant. */
165
+ readonly kind: 'team-not-found';
166
+ }
167
+ | {
168
+ /** The user isn't a member of the team. */
169
+ readonly kind: 'team-membership-not-found';
170
+ };
@@ -62,10 +62,21 @@ export interface AddProjectMemberParams {
62
62
  readonly role: ProjectRole;
63
63
  }
64
64
 
65
- export interface MembershipMutationError {
66
- readonly code: string;
67
- readonly message: string;
68
- }
65
+ export type AddTeamMemberError =
66
+ | {
67
+ /** No team with this id in the tenant. */
68
+ readonly code: 'team-not-found';
69
+ readonly message: string;
70
+ }
71
+ | { readonly code: 'add-failed'; readonly message: string; readonly cause?: unknown };
72
+
73
+ export type AddProjectMemberError =
74
+ | {
75
+ /** No project with this id in the tenant. */
76
+ readonly code: 'project-not-found';
77
+ readonly message: string;
78
+ }
79
+ | { readonly code: 'add-failed'; readonly message: string; readonly cause?: unknown };
69
80
 
70
81
  /**
71
82
  * Tenant-summary read shape returned by `getTenant`. Only the fields
@@ -96,8 +107,8 @@ export interface TenantHierarchyBinding {
96
107
  createProject(
97
108
  params: CreateProjectParams,
98
109
  ): Promise<Result<{ readonly projectId: ProjectId }, CreateProjectError>>;
99
- addTeamMember(params: AddTeamMemberParams): Promise<Result<void, MembershipMutationError>>;
100
- addProjectMember(params: AddProjectMemberParams): Promise<Result<void, MembershipMutationError>>;
110
+ addTeamMember(params: AddTeamMemberParams): Promise<Result<void, AddTeamMemberError>>;
111
+ addProjectMember(params: AddProjectMemberParams): Promise<Result<void, AddProjectMemberError>>;
101
112
  /**
102
113
  * Look up a tenant by id. Returns `null` when no tenant exists.
103
114
  * Not itself tenant-scoped: it is how a caller finds out which