From 789a3db3d9a8f58319c7889a37806570003f4365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:24:49 +0700 Subject: [PATCH 1/9] fix(orchestration): preserve verified handoff tasks --- docs/plans/execution-orchestration.json | 2 ++ .../src/check-execution-orchestration.mjs | 19 ++++++++++++++++++- .../test/execution-orchestration.test.mjs | 4 +++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index 2374cd40..0ee91463 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -161,6 +161,7 @@ "maximumChangedFiles": 260, "taskIds": [ "FND-006", + "FND-007", "IAM-001", "IAM-002", "IAM-003", @@ -173,6 +174,7 @@ "IAM-006", "IAM-007" ], + "handoffTaskIds": ["FND-007"], "exitGate": "Foundation external gates are explicit and IAM, AUD, and BUA requirements are reconciled, completed, tested, and evidenced." }, { diff --git a/tools/repo-cli/src/check-execution-orchestration.mjs b/tools/repo-cli/src/check-execution-orchestration.mjs index ddab9773..71be74a3 100644 --- a/tools/repo-cli/src/check-execution-orchestration.mjs +++ b/tools/repo-cli/src/check-execution-orchestration.mjs @@ -176,6 +176,12 @@ function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnosti diagnostics.push(`batch ${batch.batchId} has no tasks`); continue; } + const handoffTaskIds = new Set(batch.handoffTaskIds ?? []); + for (const taskId of handoffTaskIds) { + if (!batch.taskIds.includes(taskId)) { + diagnostics.push(`batch ${batch.batchId} handoff task ${taskId} is not in taskIds`); + } + } for (const taskId of batch.taskIds) { if (!taskIds.has(taskId)) diagnostics.push(`batch ${batch.batchId} has unknown task ${taskId}`); @@ -216,9 +222,20 @@ function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnosti .filter(([, state]) => ['verified', 'released'].includes(state?.status)) .map(([taskId]) => taskId), ); + for (const batch of batches) { + for (const taskId of batch.handoffTaskIds ?? []) { + if (!verifiedTasks.has(taskId)) { + diagnostics.push(`batch ${batch.batchId} handoff task ${taskId} is not verified`); + } + } + } for (const taskId of taskIds) { if (verifiedTasks.has(taskId)) { - if (batchByTask.has(taskId)) diagnostics.push(`verified task ${taskId} remains batched`); + const batchId = batchByTask.get(taskId); + const batch = batchId === undefined ? undefined : byId.get(batchId); + if (batchId !== undefined && !batch?.handoffTaskIds?.includes(taskId)) { + diagnostics.push(`verified task ${taskId} remains batched without handoff declaration`); + } } else if (!batchByTask.has(taskId)) { diagnostics.push(`unfinished task ${taskId} has no delivery batch`); } diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index 7b97c252..831e50dd 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -160,7 +160,7 @@ test('delivery batches cover every unfinished task once within review budgets', assert.equal(ledger.deliveryBatches.length, 15); assert.equal(new Set(batchedTasks).size, batchedTasks.length); assert.deepEqual( - new Set(batchedTasks), + new Set(batchedTasks.filter((taskId) => !verifiedTasks.has(taskId))), new Set([...allTasks].filter((taskId) => !verifiedTasks.has(taskId))), ); for (const batch of ledger.deliveryBatches) { @@ -174,6 +174,8 @@ test('delivery batches cover every unfinished task once within review budgets', (batch) => batch.batchId === ledger.activeBatchId, ); assert.ok(activeBatch.taskIds.includes(ledger.nextTaskId)); + assert.deepEqual(activeBatch.taskIds.slice(0, 3), ['FND-006', 'FND-007', 'IAM-001']); + assert.deepEqual(activeBatch.handoffTaskIds, ['FND-007']); }); test('the handoff runbook contains deterministic resume and failure protocols', () => { From 37f22896e849418fd516927a50e8b0b93c0b8250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:26:57 +0700 Subject: [PATCH 2/9] fix(iam): preserve project bootstrap scope --- services/api/openapi/v1.json | 3 +- .../features/iam/api/bootstrap.controller.ts | 3 + .../api/src/features/iam/api/bootstrap.dto.ts | 5 +- .../features/iam/bootstrap-controller.test.ts | 57 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index aaa35300..b5dcde0f 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -8662,13 +8662,14 @@ "properties": { "organizationId": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, + "projectId": { "type": "string", "format": "uuid" }, "authorizationEpoch": { "type": "number", "minimum": 1 } }, "required": ["organizationId", "authorizationEpoch"] }, "BootstrapPlatformDto": { "type": "object", - "properties": { "apiVersion": { "type": "string" } }, + "properties": { "apiVersion": { "type": "string", "enum": ["v1"] } }, "required": ["apiVersion"] }, "BootstrapValueDto": { diff --git a/services/api/src/features/iam/api/bootstrap.controller.ts b/services/api/src/features/iam/api/bootstrap.controller.ts index 7769d656..53667de8 100644 --- a/services/api/src/features/iam/api/bootstrap.controller.ts +++ b/services/api/src/features/iam/api/bootstrap.controller.ts @@ -79,6 +79,9 @@ export class IamBootstrapController { ...(context.tenantScope.scopeType === 'organization' ? {} : { workspaceId: context.tenantScope.workspaceId }), + ...(context.tenantScope.scopeType === 'project' + ? { projectId: context.tenantScope.projectId } + : {}), authorizationEpoch: context.authorizationEpoch, }), platform: Object.freeze({ apiVersion: 'v1' as const }), diff --git a/services/api/src/features/iam/api/bootstrap.dto.ts b/services/api/src/features/iam/api/bootstrap.dto.ts index 1b0f5f88..ae446ad1 100644 --- a/services/api/src/features/iam/api/bootstrap.dto.ts +++ b/services/api/src/features/iam/api/bootstrap.dto.ts @@ -77,12 +77,15 @@ export class BootstrapSessionDto { @ApiPropertyOptional({ format: 'uuid' }) workspaceId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + projectId?: string; + @ApiProperty({ minimum: 1 }) authorizationEpoch!: number; } export class BootstrapPlatformDto { - @ApiProperty() + @ApiProperty({ enum: ['v1'] }) apiVersion!: 'v1'; } diff --git a/services/api/test/features/iam/bootstrap-controller.test.ts b/services/api/test/features/iam/bootstrap-controller.test.ts index c80808d7..40091ad6 100644 --- a/services/api/test/features/iam/bootstrap-controller.test.ts +++ b/services/api/test/features/iam/bootstrap-controller.test.ts @@ -83,6 +83,63 @@ void test('[IAM-001, IAM-009] bootstrap controller derives the actor from the au (result as { readonly value: { readonly user: { readonly id: string } } }).value.user.id, bootstrap.user.id, ); + const value = (result as { readonly value: unknown }).value; + assert.equal( + (value as { readonly user: { readonly mfaState: string } }).user.mfaState, + 'ENABLED', + ); + assert.deepEqual((value as { readonly session: unknown }).session, { + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + authorizationEpoch: 1, + }); + const organizations = ( + value as { + readonly organizations: readonly { + readonly workspaces: readonly { readonly projects: readonly unknown[] }[]; + }[]; + } + ).organizations; + assert.deepEqual(organizations[0]?.workspaces[0]?.projects[0], { + id: bootstrap.project.id, + name: bootstrap.project.name, + kind: bootstrap.project.kind, + status: bootstrap.project.status, + }); + assert.deepEqual((value as { readonly recentScopes: unknown }).recentScopes, [ + { + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + projectId: bootstrap.project.id, + }, + ]); +}); + +void test('[IAM-001] bootstrap session preserves an authenticated project scope', async () => { + const context = { + actorId: bootstrap.user.id, + tenantScope: { + scopeType: 'project' as const, + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + projectId: bootstrap.project.id, + }, + authorizationEpoch: 4, + mfaRequired: false, + } as never; + const controller = new IamBootstrapController( + { find: async () => ({ accepted: true as const, value: bootstrap }) } as never, + { resolve: async () => context }, + ); + const result = await controller.bootstrap({}); + assert.equal((result as { readonly accepted: boolean }).accepted, true); + if (!(result as { readonly accepted: boolean }).accepted) return; + assert.deepEqual((result as { readonly value: { readonly session: unknown } }).value.session, { + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + projectId: bootstrap.project.id, + authorizationEpoch: 4, + }); }); void test('[IAM-001] bootstrap controller fails closed when durable identity storage is unavailable', async () => { From cc1118ad607873b20b65e340ec3a40b8fc0d6a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:27:31 +0700 Subject: [PATCH 3/9] fix(iam): require invitation acceptance --- .../iam/application/membership.service.ts | 2 +- .../features/iam/membership-service.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iam/application/membership.service.ts b/services/api/src/features/iam/application/membership.service.ts index e3e620b0..2d5bfbd1 100644 --- a/services/api/src/features/iam/application/membership.service.ts +++ b/services/api/src/features/iam/application/membership.service.ts @@ -248,7 +248,7 @@ export class IamMembershipService { if (authorization !== 'ALLOWED') return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); if (current.revision !== expectedRevisionInput) return rejected('CONFLICT'); - if (statusInput !== 'ACTIVE' && current.status !== 'ACTIVE') return rejected('CONFLICT'); + if (current.status !== 'ACTIVE') return rejected('CONFLICT'); if (statusInput !== 'ACTIVE' && current.roleId === 'owner') { const actor = await transaction.findMembership(context, context.actorId); if (!actor || actor.roleId !== 'owner') return rejected('SCOPE_DENIED'); diff --git a/services/api/test/features/iam/membership-service.test.ts b/services/api/test/features/iam/membership-service.test.ts index 82922f39..1b0a9ca3 100644 --- a/services/api/test/features/iam/membership-service.test.ts +++ b/services/api/test/features/iam/membership-service.test.ts @@ -248,6 +248,28 @@ void test('[IAM-004] invitee can accept an unexpired invitation and invitation l ); }); +void test('[IAM-004] administrators cannot activate invitations outside the accept flow', async () => { + const value = repository(); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + const invitation = await service.invite(context('membership-service-006b'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + }); + assert.equal(invitation.accepted, true); + if (!invitation.accepted) return; + assert.deepEqual( + await service.transition(context('membership-service-006c'), invitation.value.id, 1, 'ACTIVE'), + { accepted: false, code: 'CONFLICT' }, + ); + assert.equal( + (await value.listMemberships(context('membership-service-006d'))).find( + (membership) => membership.id === invitation.value.id, + )?.status, + 'INVITED', + ); +}); + void test('[IAM-004] invitation acceptance fails closed for an outsider, expiry, and stale revisions', async () => { const value = repository(); const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); From e98c63e9d2ac8a235c3376129504d96b63b672a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:31:05 +0700 Subject: [PATCH 4/9] fix(iam): enforce unique membership scopes --- .../migration.sql | 32 +++++++++++++++++++ services/api/prisma/schema/iam.prisma | 2 ++ .../in-memory-iam-repository.adapter.ts | 8 +++++ .../iam/application/membership.service.ts | 3 +- .../features/iam/membership-service.test.ts | 20 ++++++++++++ .../iam/prisma-iam-repository.test.ts | 2 ++ services/api/test/prisma-foundation.test.mjs | 14 ++++++++ 7 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260803030000_iam_membership_scope_uniqueness/migration.sql diff --git a/services/api/prisma/migrations/20260803030000_iam_membership_scope_uniqueness/migration.sql b/services/api/prisma/migrations/20260803030000_iam_membership_scope_uniqueness/migration.sql new file mode 100644 index 00000000..e065f5f2 --- /dev/null +++ b/services/api/prisma/migrations/20260803030000_iam_membership_scope_uniqueness/migration.sql @@ -0,0 +1,32 @@ +-- IAM-004: enforce one membership identity per principal and fully-qualified scope. +-- The expression coalesces nullable descendants so organization/workspace/project +-- scopes cannot bypass uniqueness through PostgreSQL NULL semantics. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "iam"."memberships" AS duplicate + GROUP BY + duplicate."principal_type", + duplicate."principal_id", + duplicate."scope_type", + duplicate."organization_id", + duplicate."workspace_id", + duplicate."project_id" + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'duplicate IAM membership identities must be reconciled before applying scope uniqueness'; + END IF; +END +$$; + +CREATE UNIQUE INDEX "memberships_principal_scope_identity_key" +ON "iam"."memberships" ( + "principal_type", + "principal_id", + ( + "scope_type" || ':' || "organization_id"::text || ':' || + COALESCE("workspace_id"::text, '') || ':' || + COALESCE("project_id"::text, '') + ) +); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index b75cc8db..279aff05 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -68,6 +68,8 @@ model ProjectIdentity { } model MembershipIdentity { + /// A null-safe raw expression index in 20260803030000_iam_membership_scope_uniqueness + /// enforces one principal/scope identity because Prisma composite uniques treat NULLs as distinct. id String @id @db.Uuid principalType String @map("principal_type") @db.VarChar(24) principalId String @map("principal_id") @db.Uuid diff --git a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts index a0ae8ca5..dc7c7592 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts @@ -1,5 +1,6 @@ import { tenantScopeContainsV1, + tenantScopesEqualV1, type StableIdentifierV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; @@ -54,6 +55,13 @@ export class InMemoryIamRepositoryAdapter implements IamRepositoryPortV1 { if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); const existing = this.memberships.find((item) => item.id === membership.id); + const duplicate = this.memberships.find( + (item) => + item.id !== membership.id && + item.principalId === membership.principalId && + tenantScopesEqualV1(item.scope, membership.scope), + ); + if (duplicate) throw new Error('IAM_MEMBERSHIP_CONFLICT'); if (existing && context.expectedRevision !== existing.revision) throw new Error('IAM_REVISION_CONFLICT'); if (!existing && context.expectedRevision !== undefined) diff --git a/services/api/src/features/iam/application/membership.service.ts b/services/api/src/features/iam/application/membership.service.ts index 2d5bfbd1..7a6453f2 100644 --- a/services/api/src/features/iam/application/membership.service.ts +++ b/services/api/src/features/iam/application/membership.service.ts @@ -93,7 +93,8 @@ function applicationError(error: unknown): IamMembershipApplicationCodeV1 { const message = error instanceof Error ? error.message : ''; if (message === 'IAM_SCOPE_DENIED' || message === 'IAM_SCOPE_NARROWING_REQUIRED') return 'SCOPE_DENIED'; - if (message === 'IAM_REVISION_CONFLICT') return 'CONFLICT'; + if (message === 'IAM_REVISION_CONFLICT' || message === 'IAM_MEMBERSHIP_CONFLICT') + return 'CONFLICT'; if (message.endsWith('_NOT_FOUND')) return 'NOT_FOUND'; return 'UNAVAILABLE'; } diff --git a/services/api/test/features/iam/membership-service.test.ts b/services/api/test/features/iam/membership-service.test.ts index 1b0a9ca3..11ab36d5 100644 --- a/services/api/test/features/iam/membership-service.test.ts +++ b/services/api/test/features/iam/membership-service.test.ts @@ -94,6 +94,26 @@ void test('[IAM-004] owner can create a server-identified, expiring invitation i assert.equal(result.value.scope.scopeType, 'organization'); }); +void test('[IAM-004] duplicate principal and scope invitations are rejected', async () => { + const value = repository(); + const service = new IamMembershipService( + value, + idsFrom(ids.invitation, ids.successorMembership), + clock, + ); + const input = { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer' as const, + }; + const first = await service.invite(context('membership-service-duplicate-001'), input); + assert.equal(first.accepted, true); + assert.deepEqual(await service.invite(context('membership-service-duplicate-002'), input), { + accepted: false, + code: 'CONFLICT', + }); +}); + void test('[IAM-003, IAM-004] viewer and out-of-scope invitations are denied', async () => { const viewer = repository('viewer'); const service = new IamMembershipService(viewer, idsFrom(ids.invitation), clock); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index a3f3fdf9..a4d52efa 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -292,6 +292,8 @@ void test('[IAM-004] Prisma IAM membership updates persist cleared invitation li }); assert.equal(memberships.get(id('25'))?.startsAt, null); assert.equal(memberships.get(id('25'))?.expiresAt, null); + assert.equal(memberships.get(id('25'))?.status, 'ACTIVE'); + assert.equal(memberships.get(id('25'))?.revision, 2); }); void test('[IAM-009] Prisma IAM transaction rollback leaves no staged membership', async () => { diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index ea60efed..67e612aa 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -124,6 +124,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803000000_iae_lineage_uniqueness', '20260803010000_iam_session_scope_binding', '20260803020000_bua_project_usage_scope', + '20260803030000_iam_membership_scope_uniqueness', 'migration_lock.toml', ]); const migration = await readFile( @@ -521,4 +522,17 @@ test('the schema diff and centrally ordered migration inventory establish platfo } assert.match(sessionScopeMigration, /no production or legacy data migration/u); assert.match(sessionScopeMigration, /guessing tenant scope would be unsafe/u); + const membershipUniquenessMigration = await readFile( + path.join( + migrationsDirectory, + '20260803030000_iam_membership_scope_uniqueness', + 'migration.sql', + ), + 'utf8', + ); + assert.match( + membershipUniquenessMigration, + /CREATE UNIQUE INDEX "memberships_principal_scope_identity_key"/u, + ); + assert.match(membershipUniquenessMigration, /COALESCE\("workspace_id"::text, ''\)/u); }); From 06588ea9e1c07ba317e63c569d2acc405f56131c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:42:05 +0700 Subject: [PATCH 5/9] fix(iam): align hierarchy adapter immutability checks --- ...n-memory-iam-hierarchy-repository.adapter.ts | 17 +++++++++++------ .../prisma-iam-hierarchy-repository.adapter.ts | 15 +++++++-------- .../features/iam/hierarchy-repository.test.ts | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/services/api/src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.ts index d1112ec5..72e47555 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.ts @@ -28,6 +28,14 @@ function cloneProject(value: ProjectIdentityV1): ProjectIdentityV1 { return Object.freeze({ ...value }); } +function ownedFieldsMatch(existing: TValue, expected: TValue): boolean { + const existingRecord = existing as Record; + const expectedRecord = expected as Record; + return Object.keys(expectedRecord).every((key) => + Object.is(existingRecord[key], expectedRecord[key]), + ); +} + function organizationScope(organizationId: StableIdentifierV1): TenantScopeV1 { return Object.freeze({ scopeType: 'organization', organizationId }); } @@ -188,8 +196,7 @@ export class InMemoryIamHierarchyRepositoryAdapter implements IamHierarchyReposi await Promise.resolve(); if (!organizationVisible(context, value.id)) throw new Error('IAM_SCOPE_DENIED'); const existing = this.organizations.get(value.id); - if (existing && JSON.stringify(existing) !== JSON.stringify(value)) - throw new Error('IAM_HIERARCHY_CONFLICT'); + if (existing && !ownedFieldsMatch(existing, value)) throw new Error('IAM_HIERARCHY_CONFLICT'); if (!existing) this.organizations.set(value.id, cloneOrganization(value)); } @@ -201,8 +208,7 @@ export class InMemoryIamHierarchyRepositoryAdapter implements IamHierarchyReposi if (!organizationVisible(context, value.organizationId)) throw new Error('IAM_SCOPE_DENIED'); if (!this.organizations.has(value.organizationId)) throw new Error('IAM_PARENT_NOT_FOUND'); const existing = this.workspaces.get(value.id); - if (existing && JSON.stringify(existing) !== JSON.stringify(value)) - throw new Error('IAM_HIERARCHY_CONFLICT'); + if (existing && !ownedFieldsMatch(existing, value)) throw new Error('IAM_HIERARCHY_CONFLICT'); if (!existing) this.workspaces.set(value.id, cloneWorkspace(value)); } @@ -216,8 +222,7 @@ export class InMemoryIamHierarchyRepositoryAdapter implements IamHierarchyReposi ) throw new Error('IAM_PARENT_NOT_FOUND'); const existing = this.projects.get(value.id); - if (existing && JSON.stringify(existing) !== JSON.stringify(value)) - throw new Error('IAM_HIERARCHY_CONFLICT'); + if (existing && !ownedFieldsMatch(existing, value)) throw new Error('IAM_HIERARCHY_CONFLICT'); if (!existing) this.projects.set(value.id, cloneProject(value)); } diff --git a/services/api/src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.ts index 865eefed..9cf8a46b 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.ts @@ -326,9 +326,7 @@ class PrismaIamHierarchyTransactionAdapter implements IamHierarchyTransactionPor where: { organizationId }, orderBy: { id: 'asc' }, }); - return rows - .map((row) => workspaceFromRowWithDiagnostics(row, this.diagnostics)) - .filter((workspace) => workspace.organizationId === organizationId); + return rows.map((row) => workspaceFromRowWithDiagnostics(row, this.diagnostics)); } public async findProject( @@ -348,14 +346,15 @@ class PrismaIamHierarchyTransactionAdapter implements IamHierarchyTransactionPor workspaceId: StableIdentifierV1, ): Promise { const rows = await this.client.projectIdentity.findMany({ - where: { workspaceId }, + where: { organizationId: context.tenantScope.organizationId, workspaceId }, orderBy: { id: 'asc' }, }); return rows - .map((row) => projectFromRowWithDiagnostics(row, this.diagnostics)) - .filter((project) => - projectVisible(context, project.organizationId, project.workspaceId, project.id), - ); + .filter( + (row) => + context.tenantScope.scopeType !== 'project' || row.id === context.tenantScope.projectId, + ) + .map((row) => projectFromRowWithDiagnostics(row, this.diagnostics)); } public async saveOrganization( diff --git a/services/api/test/features/iam/hierarchy-repository.test.ts b/services/api/test/features/iam/hierarchy-repository.test.ts index 3b344f3d..dc7373a5 100644 --- a/services/api/test/features/iam/hierarchy-repository.test.ts +++ b/services/api/test/features/iam/hierarchy-repository.test.ts @@ -148,6 +148,16 @@ void test('[IAM-019] hierarchy writes reject missing parents, sibling scopes, an ), /IAM_HIERARCHY_CONFLICT/u, ); + const equivalentWorkspace = workspace(ids.otherWorkspace, ids.organization, 'Finance'); + await repository.saveWorkspace(organizationContext, { + name: equivalentWorkspace.name, + id: equivalentWorkspace.id, + organizationId: equivalentWorkspace.organizationId, + schemaVersion: equivalentWorkspace.schemaVersion, + status: equivalentWorkspace.status, + authorizationEpoch: equivalentWorkspace.authorizationEpoch, + createdAt: equivalentWorkspace.createdAt, + }); }); void test('[IAM-001] hierarchy transaction rolls back all staged writes', async () => { @@ -174,4 +184,8 @@ void test('[IAM-001] hierarchy transaction rolls back all staged writes', async await repository.findOrganization(transactionContext, stable(ids.organization)), undefined, ); + assert.equal( + await repository.findWorkspace(transactionContext, stable(ids.workspace)), + undefined, + ); }); From c459a106b2a4cc5cab2c77290b4ab173517aace5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:42:09 +0700 Subject: [PATCH 6/9] fix(iam): remove hierarchy validation placeholder identities --- packages/domain/src/identity/v1.ts | 9 ++++++-- .../test/identity-hierarchy-v1.test.mjs | 10 +++++++++ .../iam/application/hierarchy.service.ts | 21 +++++-------------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/domain/src/identity/v1.ts b/packages/domain/src/identity/v1.ts index aba06af9..bf0bb45e 100644 --- a/packages/domain/src/identity/v1.ts +++ b/packages/domain/src/identity/v1.ts @@ -193,6 +193,11 @@ function boundedText(input: unknown, maxLength: number): string | undefined { return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; } +/** Shared bounded-text predicate for application-layer preflight without placeholder identities. */ +export function isBoundedTextV1(input: unknown, maxLength: number): input is string { + return boundedText(input, maxLength) !== undefined; +} + function containsControlCharacterV1(input: string): boolean { for (const character of input) { const codePoint = character.codePointAt(0); @@ -306,7 +311,7 @@ export function createUserIdentityV1(input: { export type ProjectKindV1 = 'INTERNAL' | 'CLIENT' | 'LOCATION' | 'ENGAGEMENT'; -function isProjectKind(input: unknown): input is ProjectKindV1 { +export function isProjectKindV1(input: unknown): input is ProjectKindV1 { return ( input === 'INTERNAL' || input === 'CLIENT' || input === 'LOCATION' || input === 'ENGAGEMENT' ); @@ -400,7 +405,7 @@ export function createProjectIdentityV1(input: { if (!id || !organizationId || !workspaceId) return rejected('INVALID_IDENTIFIER'); if (!name) return rejected('INVALID_TEXT'); if (!createdAt) return rejected('INVALID_TIMESTAMP'); - if (!isProjectKind(input.kind)) return rejected('INVALID_KIND'); + if (!isProjectKindV1(input.kind)) return rejected('INVALID_KIND'); if (!activeOrArchived(status)) return rejected('INVALID_STATE'); return accepted( Object.freeze({ diff --git a/packages/domain/test/identity-hierarchy-v1.test.mjs b/packages/domain/test/identity-hierarchy-v1.test.mjs index f3cde8cd..ccd9fd05 100644 --- a/packages/domain/test/identity-hierarchy-v1.test.mjs +++ b/packages/domain/test/identity-hierarchy-v1.test.mjs @@ -87,4 +87,14 @@ void test('[IAM-001] hierarchy constructors reject malformed identifiers, names, }), { accepted: false, code: 'INVALID_KIND' }, ); + assert.deepEqual( + createWorkspaceIdentityV1({ + id: ids.workspace, + organizationId: ids.organization, + name: 'Operations', + status: 'DELETED', + createdAt, + }), + { accepted: false, code: 'INVALID_STATE' }, + ); }); diff --git a/services/api/src/features/iam/application/hierarchy.service.ts b/services/api/src/features/iam/application/hierarchy.service.ts index 8d33f3b3..333ccea5 100644 --- a/services/api/src/features/iam/application/hierarchy.service.ts +++ b/services/api/src/features/iam/application/hierarchy.service.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'; import { createProjectIdentityV1, createWorkspaceIdentityV1, + isBoundedTextV1, + isProjectKindV1, type OrganizationIdentityV1, type ProjectIdentityV1, type WorkspaceIdentityV1, @@ -172,13 +174,7 @@ export class IamHierarchyService { return rejected('SCOPE_DENIED'); const createdAt = isoNow(this.clock); if (!createdAt) return rejected('UNAVAILABLE'); - const inputCheck = createWorkspaceIdentityV1({ - id: organizationId.value, - organizationId: organizationId.value, - name: nameInput, - createdAt, - }); - if (!inputCheck.accepted) return rejected(identityCode(inputCheck.code)); + if (!isBoundedTextV1(nameInput, 200)) return rejected('INVALID_TEXT'); const authorization = await this.authorizeMutation( context, PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE, @@ -251,15 +247,8 @@ export class IamHierarchyService { return rejected('SCOPE_DENIED'); const createdAt = isoNow(this.clock); if (!createdAt) return rejected('UNAVAILABLE'); - const inputCheck = createProjectIdentityV1({ - id: workspaceId.value, - organizationId: context.tenantScope.organizationId, - workspaceId: workspaceId.value, - kind: kindInput, - name: nameInput, - createdAt, - }); - if (!inputCheck.accepted) return rejected(identityCode(inputCheck.code)); + if (!isProjectKindV1(kindInput)) return rejected('INVALID_KIND'); + if (!isBoundedTextV1(nameInput, 200)) return rejected('INVALID_TEXT'); const authorization = await this.authorizeMutation( context, PERMISSIONS_V1.WORKSPACE_SETTINGS_MANAGE, From 0689d70d4ac3fcdec95927b62f1c49f4fc3e0a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:42:14 +0700 Subject: [PATCH 7/9] fix(iam): expose safe hierarchy and membership HTTP outcomes --- services/api/openapi/v1.json | 470 +++++++++++++++++- .../features/iam/api/hierarchy.controller.ts | 46 +- .../api/src/features/iam/api/hierarchy.dto.ts | 9 +- .../features/iam/api/membership.controller.ts | 128 ++++- .../src/features/iam/api/membership.dto.ts | 55 +- .../features/iam/hierarchy-controller.test.ts | 25 +- .../iam/membership-controller.test.ts | 37 ++ .../test/features/iam/membership-dto.test.ts | 79 +++ 8 files changed, 805 insertions(+), 44 deletions(-) create mode 100644 services/api/test/features/iam/membership-dto.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index b5dcde0f..c091d3da 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1951,7 +1951,7 @@ ], "responses": { "200": { - "description": "", + "description": "The membership list.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -1964,7 +1964,79 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", + "description": "The request is invalid.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -1998,6 +2070,24 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], @@ -2023,7 +2113,7 @@ }, "responses": { "200": { - "description": "", + "description": "The invited membership.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -2036,7 +2126,79 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", + "description": "The request is invalid.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -2070,6 +2232,24 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], @@ -2105,7 +2285,7 @@ }, "responses": { "200": { - "description": "", + "description": "The transitioned membership.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -2118,7 +2298,79 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", + "description": "The request is invalid.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -2152,6 +2404,24 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], @@ -2185,7 +2455,7 @@ }, "responses": { "200": { - "description": "", + "description": "The accepted membership.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -2198,7 +2468,79 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", + "description": "The request is invalid.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -2232,6 +2574,24 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], @@ -2267,7 +2627,7 @@ }, "responses": { "200": { - "description": "", + "description": "The transferred membership.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -2280,7 +2640,79 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", + "description": "The request is invalid.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -2314,6 +2746,24 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], diff --git a/services/api/src/features/iam/api/hierarchy.controller.ts b/services/api/src/features/iam/api/hierarchy.controller.ts index 040903ae..4967f779 100644 --- a/services/api/src/features/iam/api/hierarchy.controller.ts +++ b/services/api/src/features/iam/api/hierarchy.controller.ts @@ -1,4 +1,15 @@ -import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + Post, + Req, + Res, +} from '@nestjs/common'; import { ApiBearerAuth, ApiBody, @@ -17,6 +28,17 @@ import { type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; import { CreateProjectDto, CreateWorkspaceDto } from './hierarchy.dto.js'; +import type { FastifyReply } from 'fastify'; + +type HierarchyResult = { readonly accepted: boolean; readonly code?: string }; + +function preserveNotFoundStatus( + result: TValue, + reply?: FastifyReply, +): TValue { + if (!result.accepted && result.code === 'NOT_FOUND') reply?.code(HttpStatus.NOT_FOUND); + return result; +} /** IAM-001, IAM-003, IAM-019: content-free tenant hierarchy administration. */ @ApiTags('identity') @@ -36,9 +58,13 @@ export class IamHierarchyController { async getOrganization( @Req() request: unknown, @Param('organizationId') organizationId: string, + @Res({ passthrough: true }) reply?: FastifyReply, ): Promise { const context = await this.requestContext.resolve(request); - return this.hierarchy.getOrganization(context, organizationId); + return preserveNotFoundStatus( + await this.hierarchy.getOrganization(context, organizationId), + reply, + ); } @Get('organizations/:organizationId/workspaces') @@ -69,9 +95,13 @@ export class IamHierarchyController { @ApiOperation({ summary: 'Read one workspace inside the authenticated tenant scope' }) @ApiOkResponse({ description: 'The workspace metadata.' }) @ApiNotFoundResponse({ description: 'The workspace is not visible.' }) - async getWorkspace(@Req() request: unknown, @Param('workspaceId') workspaceId: string) { + async getWorkspace( + @Req() request: unknown, + @Param('workspaceId') workspaceId: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ) { const context = await this.requestContext.resolve(request); - return this.hierarchy.getWorkspace(context, workspaceId); + return preserveNotFoundStatus(await this.hierarchy.getWorkspace(context, workspaceId), reply); } @Get('workspaces/:workspaceId/projects') @@ -99,8 +129,12 @@ export class IamHierarchyController { @ApiOperation({ summary: 'Read one project inside the authenticated tenant scope' }) @ApiOkResponse({ description: 'The project metadata.' }) @ApiNotFoundResponse({ description: 'The project is not visible.' }) - async getProject(@Req() request: unknown, @Param('projectId') projectId: string) { + async getProject( + @Req() request: unknown, + @Param('projectId') projectId: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ) { const context = await this.requestContext.resolve(request); - return this.hierarchy.getProject(context, projectId); + return preserveNotFoundStatus(await this.hierarchy.getProject(context, projectId), reply); } } diff --git a/services/api/src/features/iam/api/hierarchy.dto.ts b/services/api/src/features/iam/api/hierarchy.dto.ts index 233ce0e2..a8dbc5d3 100644 --- a/services/api/src/features/iam/api/hierarchy.dto.ts +++ b/services/api/src/features/iam/api/hierarchy.dto.ts @@ -1,6 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsIn, IsString, MaxLength, MinLength } from 'class-validator'; +const PROJECT_KINDS = ['INTERNAL', 'CLIENT', 'LOCATION', 'ENGAGEMENT'] as const; +type ProjectKindDtoV1 = (typeof PROJECT_KINDS)[number]; + export class CreateWorkspaceDto { @ApiProperty({ minLength: 1, maxLength: 200 }) @IsString() @@ -10,9 +13,9 @@ export class CreateWorkspaceDto { } export class CreateProjectDto { - @ApiProperty({ enum: ['INTERNAL', 'CLIENT', 'LOCATION', 'ENGAGEMENT'] }) - @IsIn(['INTERNAL', 'CLIENT', 'LOCATION', 'ENGAGEMENT']) - kind!: 'INTERNAL' | 'CLIENT' | 'LOCATION' | 'ENGAGEMENT'; + @ApiProperty({ enum: PROJECT_KINDS }) + @IsIn(PROJECT_KINDS) + kind!: ProjectKindDtoV1; @ApiProperty({ minLength: 1, maxLength: 200 }) @IsString() diff --git a/services/api/src/features/iam/api/membership.controller.ts b/services/api/src/features/iam/api/membership.controller.ts index 43720994..72462cdd 100644 --- a/services/api/src/features/iam/api/membership.controller.ts +++ b/services/api/src/features/iam/api/membership.controller.ts @@ -1,15 +1,31 @@ import { + applyDecorators, Body, Controller, Get, HttpCode, + HttpStatus, Inject, Optional, Param, Post, Req, + Res, } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiConflictResponse, + ApiForbiddenResponse, + ApiGoneResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; +import type { FastifyReply } from 'fastify'; import { IAM_MEMBERSHIP_SERVICE, @@ -26,6 +42,56 @@ import { TransitionMembershipDto, } from './membership.dto.js'; +function membershipStatus(result: unknown): number { + if (typeof result !== 'object' || result === null || !('accepted' in result)) + return HttpStatus.SERVICE_UNAVAILABLE; + const candidate = result as { readonly accepted?: unknown; readonly code?: unknown }; + if (candidate.accepted === true) return HttpStatus.OK; + switch (candidate.code) { + case 'SCOPE_DENIED': + return HttpStatus.FORBIDDEN; + case 'NOT_FOUND': + return HttpStatus.NOT_FOUND; + case 'CONFLICT': + case 'LAST_OWNER': + return HttpStatus.CONFLICT; + case 'EXPIRED': + return HttpStatus.GONE; + case 'UNAVAILABLE': + return HttpStatus.SERVICE_UNAVAILABLE; + default: + return HttpStatus.BAD_REQUEST; + } +} + +function preserveMembershipStatus(result: TValue, reply?: FastifyReply): TValue { + reply?.code(membershipStatus(result)); + return result; +} + +function applyMembershipOutcomeResponses(): MethodDecorator { + const content = { + 'application/problem+json': { schema: { $ref: '#/components/schemas/ProblemDetails' } }, + }; + return applyDecorators( + ApiBadRequestResponse({ description: 'The request is invalid.', content }), + ApiForbiddenResponse({ + description: 'The authenticated actor lacks the required scope.', + content, + }), + ApiNotFoundResponse({ description: 'The membership is not visible.', content }), + ApiConflictResponse({ + description: 'The membership revision or ownership invariant conflicts.', + content, + }), + ApiGoneResponse({ description: 'The invitation has expired.', content }), + ApiServiceUnavailableResponse({ + description: 'Membership persistence is unavailable.', + content, + }), + ); +} + /** IAM-004: membership administration never accepts client-selected authority. */ @ApiTags('identity') @ApiBearerAuth() @@ -45,64 +111,94 @@ export class IamMembershipController { @Get() @ApiOperation({ summary: 'List memberships visible in the authenticated tenant scope' }) - async list(@Req() request: unknown): Promise { + @ApiOkResponse({ description: 'The membership list.' }) + @applyMembershipOutcomeResponses() + async list( + @Req() request: unknown, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { const context = await this.requestContext.resolve(request); - return this.memberships?.list(context) ?? this.unavailable(); + const result = this.memberships ? await this.memberships.list(context) : this.unavailable(); + return preserveMembershipStatus(result, reply); } @Post() @HttpCode(200) @ApiOperation({ summary: 'Invite a principal with a bounded role and tenant scope' }) @ApiBody({ type: InviteMembershipDto }) - async invite(@Req() request: unknown, @Body() input: InviteMembershipDto): Promise { + @ApiOkResponse({ description: 'The invited membership.' }) + @applyMembershipOutcomeResponses() + async invite( + @Req() request: unknown, + @Body() input: InviteMembershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { const context = await this.requestContext.resolve(request); - return this.memberships?.invite(context, input) ?? this.unavailable(); + const result = this.memberships + ? await this.memberships.invite(context, input) + : this.unavailable(); + return preserveMembershipStatus(result, reply); } @Post(':membershipId/transition') @HttpCode(200) @ApiOperation({ summary: 'Transition one membership with an optimistic revision' }) @ApiBody({ type: TransitionMembershipDto }) + @ApiOkResponse({ description: 'The transitioned membership.' }) + @applyMembershipOutcomeResponses() async transition( @Req() request: unknown, @Param('membershipId') membershipId: string, @Body() input: TransitionMembershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, ): Promise { const context = await this.requestContext.resolve(request); - return ( - this.memberships?.transition(context, membershipId, input.expectedRevision, input.status) ?? - this.unavailable() - ); + const result = this.memberships + ? await this.memberships.transition( + context, + membershipId, + input.expectedRevision, + input.status, + ) + : this.unavailable(); + return preserveMembershipStatus(result, reply); } @Post(':membershipId/accept') @HttpCode(200) @ApiOperation({ summary: 'Accept an invitation as the invited principal' }) @ApiBody({ type: AcceptMembershipDto }) + @ApiOkResponse({ description: 'The accepted membership.' }) + @applyMembershipOutcomeResponses() async accept( @Req() request: unknown, @Param('membershipId') membershipId: string, @Body() input: AcceptMembershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, ): Promise { const context = await this.requestContext.resolve(request); - return ( - this.memberships?.accept(context, membershipId, input.expectedRevision) ?? this.unavailable() - ); + const result = this.memberships + ? await this.memberships.accept(context, membershipId, input.expectedRevision) + : this.unavailable(); + return preserveMembershipStatus(result, reply); } @Post(':membershipId/transfer-ownership') @HttpCode(200) @ApiOperation({ summary: 'Transfer organization ownership to an active member' }) @ApiBody({ type: TransferOwnershipDto }) + @ApiOkResponse({ description: 'The transferred membership.' }) + @applyMembershipOutcomeResponses() async transferOwnership( @Req() request: unknown, @Param('membershipId') membershipId: string, @Body() input: TransferOwnershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, ): Promise { const context = await this.requestContext.resolve(request); - return ( - this.memberships?.transferOwnership(context, membershipId, input.expectedRevision) ?? - this.unavailable() - ); + const result = this.memberships + ? await this.memberships.transferOwnership(context, membershipId, input.expectedRevision) + : this.unavailable(); + return preserveMembershipStatus(result, reply); } } diff --git a/services/api/src/features/iam/api/membership.dto.ts b/services/api/src/features/iam/api/membership.dto.ts index 7acaea44..11a28864 100644 --- a/services/api/src/features/iam/api/membership.dto.ts +++ b/services/api/src/features/iam/api/membership.dto.ts @@ -1,11 +1,52 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsIn, IsInt, IsOptional, IsUUID, Max, Min, ValidateNested } from 'class-validator'; +import { + IsIn, + IsInt, + IsOptional, + IsUUID, + Max, + Min, + Validate, + ValidateNested, + ValidatorConstraint, +} from 'class-validator'; +import type { ValidationArguments, ValidatorConstraintInterface } from 'class-validator'; + +const MEMBERSHIP_SCOPE_TYPES = ['organization', 'workspace', 'project'] as const; +const MEMBERSHIP_ROLE_IDS = [ + 'owner', + 'admin', + 'analyst', + 'operator', + 'approver', + 'viewer', +] as const; +type MembershipScopeTypeDtoV1 = (typeof MEMBERSHIP_SCOPE_TYPES)[number]; +type MembershipRoleIdDtoV1 = (typeof MEMBERSHIP_ROLE_IDS)[number]; + +@ValidatorConstraint({ name: 'membershipScopeShape', async: false }) +class MembershipScopeShapeConstraint implements ValidatorConstraintInterface { + validate(_value: unknown, args: ValidationArguments): boolean { + const scope = args.object as Partial; + if (!MEMBERSHIP_SCOPE_TYPES.includes(scope.scopeType as MembershipScopeTypeDtoV1)) return true; + if (scope.scopeType === 'organization') + return scope.workspaceId === undefined && scope.projectId === undefined; + if (typeof scope.workspaceId !== 'string') return false; + if (scope.scopeType === 'workspace') return scope.projectId === undefined; + return typeof scope.projectId === 'string'; + } + + defaultMessage(): string { + return 'workspaceId and projectId must match scopeType'; + } +} export class MembershipScopeDto { - @ApiProperty({ enum: ['organization', 'workspace', 'project'] }) - @IsIn(['organization', 'workspace', 'project']) - scopeType!: 'organization' | 'workspace' | 'project'; + @ApiProperty({ enum: MEMBERSHIP_SCOPE_TYPES }) + @IsIn(MEMBERSHIP_SCOPE_TYPES) + @Validate(MembershipScopeShapeConstraint) + scopeType!: MembershipScopeTypeDtoV1; @ApiProperty({ format: 'uuid' }) @IsUUID() @@ -32,9 +73,9 @@ export class InviteMembershipDto { @Type(() => MembershipScopeDto) scope!: MembershipScopeDto; - @ApiProperty({ enum: ['owner', 'admin', 'analyst', 'operator', 'approver', 'viewer'] }) - @IsIn(['owner', 'admin', 'analyst', 'operator', 'approver', 'viewer']) - roleId!: 'owner' | 'admin' | 'analyst' | 'operator' | 'approver' | 'viewer'; + @ApiProperty({ enum: MEMBERSHIP_ROLE_IDS }) + @IsIn(MEMBERSHIP_ROLE_IDS) + roleId!: MembershipRoleIdDtoV1; } export class TransitionMembershipDto { diff --git a/services/api/test/features/iam/hierarchy-controller.test.ts b/services/api/test/features/iam/hierarchy-controller.test.ts index b2605507..3385068c 100644 --- a/services/api/test/features/iam/hierarchy-controller.test.ts +++ b/services/api/test/features/iam/hierarchy-controller.test.ts @@ -109,6 +109,7 @@ void test('[IAM-001, IAM-003] hierarchy controller forwards authenticated contex value: { id: ids.project }, }); assert.equal(calls.length, 7); + for (const call of calls) assert.equal(call[0], context); assert.equal(calls[2]?.[1], ids.organization); assert.equal(calls[2]?.[2], 'Operations'); assert.equal(calls[5]?.[1], ids.workspace); @@ -121,10 +122,10 @@ void test('[IAM-003, IAM-019] hierarchy controller preserves safe rejected servi getOrganization: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), listWorkspaces: async () => ({ accepted: false as const, code: 'SCOPE_DENIED' as const }), createWorkspace: async () => ({ accepted: false as const, code: 'CONFLICT' as const }), - getWorkspace: async () => ({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }), + getWorkspace: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), listProjects: async () => ({ accepted: false as const, code: 'UNAVAILABLE' as const }), createProject: async () => ({ accepted: false as const, code: 'INVALID_KIND' as const }), - getProject: async () => ({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }), + getProject: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), } as unknown as IamHierarchyService; const controller = new IamHierarchyController(service, { resolve: async () => tenantContext(), @@ -133,6 +134,26 @@ void test('[IAM-003, IAM-019] hierarchy controller preserves safe rejected servi accepted: false, code: 'NOT_FOUND', }); + const statuses: number[] = []; + const reply = { + code(status: number) { + statuses.push(status); + return this; + }, + }; + assert.deepEqual(await controller.getOrganization({}, ids.organization, reply as never), { + accepted: false, + code: 'NOT_FOUND', + }); + assert.deepEqual(await controller.getWorkspace({}, ids.workspace, reply as never), { + accepted: false, + code: 'NOT_FOUND', + }); + assert.deepEqual(await controller.getProject({}, ids.project, reply as never), { + accepted: false, + code: 'NOT_FOUND', + }); + assert.deepEqual(statuses, [404, 404, 404]); assert.deepEqual( await controller.createProject({}, ids.workspace, { kind: 'CLIENT', name: 'x' }), { diff --git a/services/api/test/features/iam/membership-controller.test.ts b/services/api/test/features/iam/membership-controller.test.ts index 66c5924d..e70e6014 100644 --- a/services/api/test/features/iam/membership-controller.test.ts +++ b/services/api/test/features/iam/membership-controller.test.ts @@ -76,3 +76,40 @@ void test('[IAM-004] membership controller fails closed when durable membership const controller = new IamMembershipController(undefined, { resolve: async () => ({}) as never }); assert.deepEqual(await controller.list({}), { accepted: false, code: 'UNAVAILABLE' }); }); + +void test('[IAM-004] membership controller maps rejected results to HTTP status codes', async () => { + const statuses: number[] = []; + const reply = { + code(status: number) { + statuses.push(status); + return this; + }, + }; + const service = { + list: async () => ({ accepted: false as const, code: 'SCOPE_DENIED' as const }), + invite: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), + transition: async () => ({ accepted: false as const, code: 'CONFLICT' as const }), + accept: async () => ({ accepted: false as const, code: 'EXPIRED' as const }), + transferOwnership: async () => ({ accepted: false as const, code: 'UNAVAILABLE' as const }), + } as unknown as IamMembershipService; + const controller = new IamMembershipController(service, { resolve: async () => ({}) as never }); + await controller.list({}, reply as never); + await controller.invite( + {}, + { + principalId: 'principal', + scope: { scopeType: 'organization', organizationId: 'org' }, + roleId: 'viewer', + }, + reply as never, + ); + await controller.transition( + {}, + 'membership-id', + { expectedRevision: 1, status: 'SUSPENDED' }, + reply as never, + ); + await controller.accept({}, 'membership-id', { expectedRevision: 1 }, reply as never); + await controller.transferOwnership({}, 'membership-id', { expectedRevision: 1 }, reply as never); + assert.deepEqual(statuses, [403, 404, 409, 410, 503]); +}); diff --git a/services/api/test/features/iam/membership-dto.test.ts b/services/api/test/features/iam/membership-dto.test.ts new file mode 100644 index 00000000..2265b1aa --- /dev/null +++ b/services/api/test/features/iam/membership-dto.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { validate } from 'class-validator'; + +import { MembershipScopeDto } from '../../../src/features/iam/api/membership.dto.js'; + +const ids = { + organization: '00000000-0000-4000-8000-000000000701', + workspace: '00000000-0000-4000-8000-000000000702', + project: '00000000-0000-4000-8000-000000000703', +}; + +async function errors(input: Partial) { + const value = Object.assign(new MembershipScopeDto(), input); + return validate(value); +} + +void test('[IAM-004] membership scope DTO accepts matching hierarchy identifiers', async () => { + assert.equal( + ( + await errors({ + scopeType: 'organization', + organizationId: ids.organization, + }) + ).length, + 0, + ); + assert.equal( + ( + await errors({ + scopeType: 'workspace', + organizationId: ids.organization, + workspaceId: ids.workspace, + }) + ).length, + 0, + ); + assert.equal( + ( + await errors({ + scopeType: 'project', + organizationId: ids.organization, + workspaceId: ids.workspace, + projectId: ids.project, + }) + ).length, + 0, + ); +}); + +void test('[IAM-004] membership scope DTO rejects inconsistent hierarchy identifiers', async () => { + assert.ok( + ( + await errors({ + scopeType: 'organization', + organizationId: ids.organization, + projectId: ids.project, + }) + ).some((error) => error.property === 'scopeType'), + ); + assert.ok( + ( + await errors({ + scopeType: 'workspace', + organizationId: ids.organization, + }) + ).some((error) => error.property === 'scopeType'), + ); + assert.ok( + ( + await errors({ + scopeType: 'project', + organizationId: ids.organization, + workspaceId: ids.workspace, + }) + ).some((error) => error.property === 'scopeType'), + ); +}); From de3ff3d8519f57ed97f932b7c57729e99f219a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:42:17 +0700 Subject: [PATCH 8/9] docs(operations): clarify Windows Android test commands --- docs/operations/foundation-handoff-2026-08-03.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/operations/foundation-handoff-2026-08-03.md b/docs/operations/foundation-handoff-2026-08-03.md index d7300dcf..69bef29a 100644 --- a/docs/operations/foundation-handoff-2026-08-03.md +++ b/docs/operations/foundation-handoff-2026-08-03.md @@ -26,9 +26,12 @@ The following evidence is reproducible from the checkpoint: hostile-input, exporter-isolation, and cross-runtime source parity tests pass. - `uv run pytest tests/test_telemetry.py` from `services/engine` — Python telemetry tests pass. -- `ANDROID_HOME=%LOCALAPPDATA%\\Android\\Sdk apps/android/gradlew.bat - :app:testDebugUnitTest --offline --no-daemon` — Android/Kotlin unit suite - passes when the SDK is supplied by the workstation/toolchain. +- PowerShell: `$env:ANDROID_HOME = Join-Path $env:LOCALAPPDATA 'Android\\Sdk'`, then + `& .\\apps\\android\\gradlew.bat :app:testDebugUnitTest --offline --no-daemon` — + Android/Kotlin unit suite passes when the SDK is supplied by the workstation/toolchain. +- cmd.exe: `set "ANDROID_HOME=%LOCALAPPDATA%\\Android\\Sdk"`, then + `call apps\\android\\gradlew.bat :app:testDebugUnitTest --offline --no-daemon` — + the same Android/Kotlin unit suite passes from a Windows command prompt. - `corepack pnpm orchestration:check` and `corepack pnpm requirements:check` pass with 611 requirement records and the B01 dependency graph intact. - Existing root checks, API tests, OpenAPI drift checks, infrastructure static From fa081b5397ad8cf1eaa14009da55eb87b0e7d0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 22:42:46 +0700 Subject: [PATCH 9/9] docs(review): record CodeRabbit PR 40 disposition --- .../coderabbit-pr-40-disposition.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/operations/coderabbit-pr-40-disposition.md diff --git a/docs/operations/coderabbit-pr-40-disposition.md b/docs/operations/coderabbit-pr-40-disposition.md new file mode 100644 index 00000000..3ba7e275 --- /dev/null +++ b/docs/operations/coderabbit-pr-40-disposition.md @@ -0,0 +1,40 @@ +# CodeRabbit disposition for promotion PR 40 + +Promotion PR [#40](https://github.com/DatabreezeService/databreeze-platform/pull/40) +received exactly one automatic full CodeRabbit review. No manual rerun or second +review was requested. + +- Review ID: `4845720374` +- Run ID: `2397e2ad-4258-4b05-9516-0a8b6fb4f39c` +- Submitted: `2026-08-03T15:20:07Z` +- Reviewed range: `8a4c0af52ed872715103710e3c89ca832f999bd4..f1573921446e9f86313e0f58b926777aed9e1402` + +## Valid findings fixed + +All six actionable inline findings, the outside-diff orchestration finding, and +the twelve review-body nitpicks were reproduced against the reviewed code and +fixed in focused commits on `fix/coderabbit-pr-40-reconciliation`: + +| Finding | Disposition and evidence | +|---|---| +| FND-007 was omitted from B01 task traversal. | Accepted. `789a3db` records `FND-007` as an explicit handoff task and asserts its position in the orchestration checker. | +| Project-scoped bootstrap sessions lost `projectId`; `apiVersion` was too broad. | Accepted. `37f2289` preserves project scope and constrains the generated API schema. | +| Invitation and removed memberships could be activated through `transition`. | Accepted. `cc1118a` requires an existing `ACTIVE` membership for administrative transitions; invitation activation remains in `accept`. | +| Membership identity uniqueness did not cover nullable scope components. | Accepted. `e98c63e` adds the null-safe PostgreSQL uniqueness index, in-memory parity, conflict mapping, and migration inventory coverage. | +| Hierarchy reads and membership outcomes returned denial/not-found/conflict envelopes as HTTP 200. | Accepted. `0689d70` maps hierarchy `NOT_FOUND` to 404 and membership result codes to 400/403/404/409/410/503, with generated OpenAPI and regression tests. | +| Windows Android test command mixed PowerShell and cmd.exe syntax. | Accepted. `de3ff3d` documents valid commands for both shells. | +| Maintainability and boundary nitpicks (shared DTO constants, cross-field scope validation, identity state coverage, adapter equality/filtering, rollback assertions, and mapped bootstrap assertions). | Accepted. These are covered by `c459a10`, `06588ea`, `0689d70`, and the preceding `37f2289` test changes. | + +## Rejected findings + +None. Every posted actionable finding and review-body nitpick had a reproducible +correctness, contract, security, or test-coverage improvement in this slice. + +## Verification and merge rule + +The focused fixes must pass the affected API/domain tests, OpenAPI drift check, +`corepack pnpm repo:check`, `corepack pnpm repo:build`, and the hosted checks on +the follow-up `dev` PR. This document records the single-review disposition; it +does not authorize a second CodeRabbit run. PR #40 remains unmergeable until the +fix PR is merged to `dev`, its promotion checks are green, and all valid findings +are resolved.