From 8022db16851cb63f055331bdc9cb6a0dfbbdb81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:04:35 +0700 Subject: [PATCH 01/74] feat(iae): expose exact artifact version reads --- .../iae/api/artifact-read.controller.ts | 53 +++++++ services/api/src/features/iae/iae.module.ts | 3 +- .../iae/artifact-read.controller.test.ts | 137 ++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/api/artifact-read.controller.ts create mode 100644 services/api/test/features/iae/artifact-read.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-read.controller.ts b/services/api/src/features/iae/api/artifact-read.controller.ts new file mode 100644 index 00000000..d2df83b2 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-read.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ArtifactService } from '../application/artifact.service.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-006, IAE-008, IAE-019, IAE-020: content-free exact-version reads. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ArtifactReadController { + private readonly artifacts: ArtifactService; + + public constructor( + @Inject(ARTIFACT_REPOSITORY_PORT) repository: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.artifacts = new ArtifactService(repository); + } + + @Get(':versionId') + @ApiOperation({ summary: 'Read immutable artifact-version metadata and placements' }) + async get(@Req() request: unknown, @Param('versionId') versionIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.artifacts.find(context, versionId.value); + if (!result.version) return { accepted: false, code: 'NOT_FOUND' as const }; + return Object.freeze({ accepted: true, value: result }); + } + + @Get(':versionId/evidence') + @ApiOperation({ summary: 'List typed evidence references for one immutable version' }) + async evidence( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.artifacts.find(context, versionId.value); + if (!result.version) return { accepted: false, code: 'NOT_FOUND' as const }; + return Object.freeze({ accepted: true, value: result.evidence }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index bff8b5a2..fc7a25d6 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -2,6 +2,7 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { InboxController } from './api/inbox.controller.js'; import { EvidenceGrantController } from './api/evidence-grant.controller.js'; +import { ArtifactReadController } from './api/artifact-read.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -47,7 +48,7 @@ export class IaeModule { public static register(options: IaeModuleOptions = {}): DynamicModule { return { module: IaeModule, - controllers: [InboxController, EvidenceGrantController], + controllers: [InboxController, EvidenceGrantController, ArtifactReadController], providers: [ { provide: ARTIFACT_INTAKE_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts new file mode 100644 index 00000000..5b0f6904 --- /dev/null +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -0,0 +1,137 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000621'; +const workspaceId = '00000000-0000-4000-8000-000000000622'; +const artifactId = '00000000-0000-4000-8000-000000000623'; +const versionId = '00000000-0000-4000-8000-000000000624'; +const placementId = '00000000-0000-4000-8000-000000000625'; +const evidenceId = '00000000-0000-4000-8000-000000000626'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000627', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000628', + idempotencyKey: 'artifact-read', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact content-free metadata', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context(); + const service = new ArtifactService(repository); + const created = await service.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'a'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'LOCAL', + opaqueReference: 'local-placement-000001', + contentSha256: 'a'.repeat(64), + }, + evidence: { + evidenceId, + tenantScope: tenantContext.tenantScope, + coordinate: { kind: 'ROW', row: 1, field: 'amount' }, + }, + }); + assert.equal(created.accepted, true); + + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ method: 'GET', url: `/v1/artifact-versions/${versionId}` }); + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.equal(body.accepted, true); + assert.equal(body.value.version.versionId, versionId); + assert.equal(body.value.placements[0].opaqueReference, 'local-placement-000001'); + assert.doesNotMatch(response.body, /C:\\|\\\\|sourcePath|localPath/u); + + const evidenceResponse = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${versionId}/evidence`, + }); + assert.equal(evidenceResponse.statusCode, 200); + assert.deepEqual(evidenceResponse.json().value[0].coordinate, { + kind: 'ROW', + row: 1, + field: 'amount', + }); + } finally { + await app.close(); + } +}); + +void test('[IAE-008, IAM-009] artifact reads do not enumerate a sibling workspace', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context(); + const service = new ArtifactService(repository); + await service.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Cloud', + contentSha256: 'b'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'private.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000001', + contentSha256: 'b'.repeat(64), + }, + }); + const sibling = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000629', + tenantScope: { + scopeType: 'workspace', + organizationId, + workspaceId: '00000000-0000-4000-8000-000000000630', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000631', + idempotencyKey: 'artifact-read-sibling', + }); + assert.equal(sibling.accepted, true); + if (!sibling.accepted) return; + const parsedVersionId = parseStableIdentifierV1(versionId); + assert.equal(parsedVersionId.accepted, true); + if (!parsedVersionId.accepted) return; + const result = await service.find(sibling.value, parsedVersionId.value); + assert.equal(result.version, undefined); +}); From b06482a80c555b120d6411bcebd69b4b33f65112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:05:37 +0700 Subject: [PATCH 02/74] feat(iae): expose artifact lineage reads --- .../iae/api/artifact-lineage.controller.ts | 58 ++++++++++++++++ services/api/src/features/iae/iae.module.ts | 20 +++++- .../iae/artifact-lineage.controller.test.ts | 67 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/api/artifact-lineage.controller.ts create mode 100644 services/api/test/features/iae/artifact-lineage.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-lineage.controller.ts b/services/api/src/features/iae/api/artifact-lineage.controller.ts new file mode 100644 index 00000000..0f191fc5 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-lineage.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + ARTIFACT_LINEAGE_REPOSITORY_PORT, + type ArtifactLineageRepositoryPortV1, +} from '../application/artifact-lineage-repository.port.js'; +import { ArtifactGovernanceService } from '../application/artifact-governance.service.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-007: lineage is addressable by exact derived and source versions. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ArtifactLineageController { + private readonly governance: ArtifactGovernanceService; + + public constructor( + @Inject(ARTIFACT_LINEAGE_REPOSITORY_PORT) repository: ArtifactLineageRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.governance = new ArtifactGovernanceService(repository); + } + + @Get(':versionId/lineage') + @ApiOperation({ summary: 'Read lineage for an exact derived artifact version' }) + async forDerived( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const lineage = await this.governance.findForDerived(context, versionId.value); + return lineage + ? Object.freeze({ accepted: true, value: lineage }) + : Object.freeze({ accepted: false, code: 'NOT_FOUND' as const }); + } + + @Get(':versionId/derived-lineage') + @ApiOperation({ summary: 'List derived versions that use an exact source version' }) + async forSource( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return Object.freeze({ + accepted: true, + value: await this.governance.listForSource(context, versionId.value), + }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index fc7a25d6..3c428536 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -3,12 +3,14 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { InboxController } from './api/inbox.controller.js'; import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { ArtifactReadController } from './api/artifact-read.controller.js'; +import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, type ArtifactIntakeDatabaseClientV1, } from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; +import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -22,6 +24,10 @@ import { ARTIFACT_REPOSITORY_PORT, type ArtifactRepositoryPortV1, } from './application/artifact-repository.port.js'; +import { + ARTIFACT_LINEAGE_REPOSITORY_PORT, + type ArtifactLineageRepositoryPortV1, +} from './application/artifact-lineage-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -39,6 +45,7 @@ export interface IaeModuleOptions { readonly artifactRepository?: ArtifactRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactDatabase?: ArtifactDatabaseClientV1; + readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -48,7 +55,12 @@ export class IaeModule { public static register(options: IaeModuleOptions = {}): DynamicModule { return { module: IaeModule, - controllers: [InboxController, EvidenceGrantController, ArtifactReadController], + controllers: [ + InboxController, + EvidenceGrantController, + ArtifactReadController, + ArtifactLineageController, + ], providers: [ { provide: ARTIFACT_INTAKE_REPOSITORY_PORT, @@ -66,6 +78,11 @@ export class IaeModule { ? new InMemoryArtifactRepositoryAdapter() : new PrismaArtifactRepositoryAdapter(options.artifactDatabase)), }, + { + provide: ARTIFACT_LINEAGE_REPOSITORY_PORT, + useValue: + options.artifactLineageRepository ?? new InMemoryArtifactLineageRepositoryAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), @@ -78,6 +95,7 @@ export class IaeModule { exports: [ ARTIFACT_INTAKE_REPOSITORY_PORT, ARTIFACT_REPOSITORY_PORT, + ARTIFACT_LINEAGE_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-lineage.controller.test.ts b/services/api/test/features/iae/artifact-lineage.controller.test.ts new file mode 100644 index 00000000..6219ae49 --- /dev/null +++ b/services/api/test/features/iae/artifact-lineage.controller.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactLineageRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { ArtifactGovernanceService } from '../../../src/features/iae/application/artifact-governance.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000641'; +const workspaceId = '00000000-0000-4000-8000-000000000642'; +const sourceVersionId = '00000000-0000-4000-8000-000000000643'; +const derivedVersionId = '00000000-0000-4000-8000-000000000644'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000645', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000646', + idempotencyKey: 'lineage-http', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-007] lineage endpoints resolve exact derived and source versions', async () => { + const repository = new InMemoryArtifactLineageRepositoryAdapter(); + const tenantContext = context(); + const governance = new ArtifactGovernanceService(repository); + const created = await governance.registerLineage(tenantContext, { + lineageId: '00000000-0000-4000-8000-000000000647', + derivedArtifactVersionId: derivedVersionId, + tenantScope: tenantContext.tenantScope, + sourceArtifactVersionIds: [sourceVersionId], + sourceTenantScopes: [tenantContext.tenantScope], + processorVersion: 'spreadsheet-auditor@1', + coordinateLineage: [], + }); + assert.equal(created.accepted, true); + + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactLineageRepository: repository, + requestTenantContext, + }); + try { + const derived = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${derivedVersionId}/lineage`, + }); + assert.equal(derived.statusCode, 200); + assert.equal(derived.json().value.derivedArtifactVersionId, derivedVersionId); + + const source = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${sourceVersionId}/derived-lineage`, + }); + assert.equal(source.statusCode, 200); + assert.equal(source.json().value.length, 1); + } finally { + await app.close(); + } +}); From 56ed43af47c7a94cc9b443db8778587894b32745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:08:10 +0700 Subject: [PATCH 03/74] feat(iae): version content placement availability --- packages/domain/src/artifact/v1.ts | 20 +++++ .../in-memory-artifact-repository.adapter.ts | 19 +++++ .../prisma-artifact-repository.adapter.ts | 43 +++++++++++ .../iae/api/content-placement.controller.ts | 44 +++++++++++ .../features/iae/api/content-placement.dto.ts | 13 ++++ .../application/artifact-repository.port.ts | 1 + .../application/content-placement.service.ts | 55 ++++++++++++++ services/api/src/features/iae/iae.module.ts | 2 + .../iae/content-placement.service.test.ts | 74 +++++++++++++++++++ .../iae/prisma-artifact-repository.test.ts | 12 +++ 10 files changed, 283 insertions(+) create mode 100644 services/api/src/features/iae/api/content-placement.controller.ts create mode 100644 services/api/src/features/iae/api/content-placement.dto.ts create mode 100644 services/api/src/features/iae/application/content-placement.service.ts create mode 100644 services/api/test/features/iae/content-placement.service.test.ts diff --git a/packages/domain/src/artifact/v1.ts b/packages/domain/src/artifact/v1.ts index d1732b48..df93850b 100644 --- a/packages/domain/src/artifact/v1.ts +++ b/packages/domain/src/artifact/v1.ts @@ -84,6 +84,7 @@ export type ArtifactErrorCodeV1 = | 'INVALID_NAME' | 'INVALID_STATUS' | 'INVALID_REVISION' + | 'REVISION_CONFLICT' | 'INVALID_REFERENCE' | 'INVALID_COORDINATE' | 'COORDINATE_OUT_OF_BOUNDS' @@ -271,6 +272,25 @@ export function createContentPlacementV1(input: { ); } +/** IAE-020: placement availability is a revisioned projection, not mutable content identity. */ +export function updateContentPlacementAvailabilityV1( + placement: ContentPlacementV1, + availableInput: unknown, + expectedRevisionInput: unknown, +): ArtifactResultV1 { + if (typeof availableInput !== 'boolean') return rejected('INVALID_STATUS'); + if (!positiveRevision(expectedRevisionInput)) return rejected('INVALID_REVISION'); + if (expectedRevisionInput !== placement.revision) return rejected('REVISION_CONFLICT'); + if (availableInput === placement.available) return accepted(placement); + return accepted( + Object.freeze({ + ...placement, + available: availableInput, + revision: placement.revision + 1, + }), + ); +} + function evidenceCoordinate(input: unknown): EvidenceCoordinateV1 | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; const record = input as Record; diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts index 10748ee8..e3cc3d84 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts @@ -92,6 +92,24 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort .map(clonePlacement); } + async updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { + await Promise.resolve(); + if (!scopeAllowsMutation(context, placement.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.placements.get(placement.placementId); + if (!existing) throw new Error('IAE_PLACEMENT_NOT_FOUND'); + if (JSON.stringify(existing) === JSON.stringify(placement)) return; + if (placement.revision !== existing.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + existing.artifactVersionId !== placement.artifactVersionId || + existing.kind !== placement.kind || + existing.opaqueReference !== placement.opaqueReference || + existing.contentSha256 !== placement.contentSha256 + ) + throw new Error('IAE_IMMUTABLE_PLACEMENT'); + this.placements.set(placement.placementId, clonePlacement(placement)); + } + async saveEvidence(context: IamTenantContextV1, evidence: EvidenceReferenceV1): Promise { await Promise.resolve(); if (!scopeAllowsMutation(context, evidence.tenantScope)) @@ -135,6 +153,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort saveVersion: this.saveVersion.bind(this), findVersion: this.findVersion.bind(this), savePlacement: this.savePlacement.bind(this), + updatePlacement: this.updatePlacement.bind(this), listPlacements: this.listPlacements.bind(this), saveEvidence: this.saveEvidence.bind(this), listEvidence: this.listEvidence.bind(this), diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index da6d83cb..f4dd3950 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -90,6 +90,13 @@ export interface ArtifactDatabaseClientV1 { findMany(input: { readonly where: Readonly>; }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly available: boolean; readonly revision: number }; + }): Promise; }; readonly evidenceReference: { create(input: { readonly data: EvidenceCreateDataV1 }): Promise; @@ -269,6 +276,36 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { .map((row) => rowToPlacement(row, version)); } + public async updatePlacement( + context: IamTenantContextV1, + placement: ContentPlacementV1, + ): Promise { + const existing = await this.client.contentPlacement.findUnique({ + where: { id: placement.placementId }, + }); + if (existing === null) throw new Error('IAE_PLACEMENT_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, placement.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const versionRow = await this.client.artifactVersion.findUnique({ + where: { id: placement.artifactVersionId }, + }); + if (versionRow === null) throw new Error('IAE_VERSION_NOT_FOUND'); + const current = rowToPlacement(existing, rowToVersion(versionRow)); + if (JSON.stringify(current) === JSON.stringify(placement)) return; + if (placement.revision !== current.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + current.artifactVersionId !== placement.artifactVersionId || + current.kind !== placement.kind || + current.opaqueReference !== placement.opaqueReference || + current.contentSha256 !== placement.contentSha256 + ) + throw new Error('IAE_IMMUTABLE_PLACEMENT'); + await this.client.contentPlacement.update({ + where: { id: placement.placementId }, + data: { available: placement.available, revision: placement.revision }, + }); + } + public async saveEvidence( context: IamTenantContextV1, evidence: EvidenceReferenceV1, @@ -330,6 +367,12 @@ export class PrismaArtifactRepositoryAdapter implements ArtifactRepositoryPortV1 public savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { return new PrismaArtifactTransactionAdapter(this.client).savePlacement(context, placement); } + public updatePlacement( + context: IamTenantContextV1, + placement: ContentPlacementV1, + ): Promise { + return new PrismaArtifactTransactionAdapter(this.client).updatePlacement(context, placement); + } public listPlacements( context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], diff --git a/services/api/src/features/iae/api/content-placement.controller.ts b/services/api/src/features/iae/api/content-placement.controller.ts new file mode 100644 index 00000000..f56b5328 --- /dev/null +++ b/services/api/src/features/iae/api/content-placement.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Inject, Param, Patch, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ContentPlacementService } from '../application/content-placement.service.js'; +import { UpdateContentPlacementDto } from './content-placement.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ContentPlacementController { + private readonly placements: ContentPlacementService; + + public constructor( + @Inject(ARTIFACT_REPOSITORY_PORT) repository: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.placements = new ContentPlacementService(repository); + } + + @Patch(':versionId/placements/:placementId') + @ApiOperation({ summary: 'Update verified placement availability with a revision precondition' }) + @ApiBody({ type: UpdateContentPlacementDto }) + async update( + @Req() request: unknown, + @Param('versionId') versionId: string, + @Param('placementId') placementId: string, + @Body() input: UpdateContentPlacementDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.placements.setAvailability(context, { + versionId, + placementId, + ...input, + }); + } +} diff --git a/services/api/src/features/iae/api/content-placement.dto.ts b/services/api/src/features/iae/api/content-placement.dto.ts new file mode 100644 index 00000000..fdfcafc3 --- /dev/null +++ b/services/api/src/features/iae/api/content-placement.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsInt, Min } from 'class-validator'; + +export class UpdateContentPlacementDto { + @ApiProperty() + @IsBoolean() + available!: boolean; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} diff --git a/services/api/src/features/iae/application/artifact-repository.port.ts b/services/api/src/features/iae/application/artifact-repository.port.ts index a2617cd4..0db52f18 100644 --- a/services/api/src/features/iae/application/artifact-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-repository.port.ts @@ -15,6 +15,7 @@ export interface ArtifactTransactionPortV1 { versionId: ArtifactVersionV1['versionId'], ): Promise; savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; + updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; listPlacements( context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], diff --git a/services/api/src/features/iae/application/content-placement.service.ts b/services/api/src/features/iae/application/content-placement.service.ts new file mode 100644 index 00000000..4ad9c0f3 --- /dev/null +++ b/services/api/src/features/iae/application/content-placement.service.ts @@ -0,0 +1,55 @@ +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + updateContentPlacementAvailabilityV1, + type ArtifactResultV1, + type ContentPlacementV1, +} from '@databreeze/domain/artifact/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; + +export type ContentPlacementServiceErrorV1 = 'VERSION_NOT_FOUND' | 'PLACEMENT_NOT_FOUND'; +export type ContentPlacementServiceResultV1 = + | ArtifactResultV1 + | { readonly accepted: false; readonly code: ContentPlacementServiceErrorV1 }; + +/** Updates only verified availability state while preserving opaque placement identity. */ +export class ContentPlacementService { + public constructor(private readonly repository: ArtifactRepositoryPortV1) {} + + public async setAvailability( + context: IamTenantContextV1, + input: { + readonly versionId: unknown; + readonly placementId: unknown; + readonly available: unknown; + readonly expectedRevision: unknown; + }, + ): Promise> { + const versionId = parseStableIdentifierV1(input.versionId); + const placementId = parseStableIdentifierV1(input.placementId); + if (!versionId.accepted || !placementId.accepted) + return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); + return this.repository.withTransaction(context, async (transaction) => { + const version = await transaction.findVersion(context, versionId.value); + if (!version) + return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + const current = (await transaction.listPlacements(context, version.versionId)).find( + (candidate) => candidate.placementId === placementId.value, + ); + if (!current) + return Object.freeze({ accepted: false as const, code: 'PLACEMENT_NOT_FOUND' as const }); + const updated = updateContentPlacementAvailabilityV1( + current, + input.available, + input.expectedRevision, + ); + if (!updated.accepted) return updated; + await transaction.updatePlacement(context, updated.value); + return updated; + }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 3c428536..d1d52040 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -4,6 +4,7 @@ import { InboxController } from './api/inbox.controller.js'; import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { ArtifactReadController } from './api/artifact-read.controller.js'; import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; +import { ContentPlacementController } from './api/content-placement.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -60,6 +61,7 @@ export class IaeModule { EvidenceGrantController, ArtifactReadController, ArtifactLineageController, + ContentPlacementController, ], providers: [ { diff --git a/services/api/test/features/iae/content-placement.service.test.ts b/services/api/test/features/iae/content-placement.service.test.ts new file mode 100644 index 00000000..040d63c6 --- /dev/null +++ b/services/api/test/features/iae/content-placement.service.test.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { ContentPlacementService } from '../../../src/features/iae/application/content-placement.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000651'; +const workspaceId = '00000000-0000-4000-8000-000000000652'; +const artifactId = '00000000-0000-4000-8000-000000000653'; +const versionId = '00000000-0000-4000-8000-000000000654'; +const placementId = '00000000-0000-4000-8000-000000000655'; + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000656', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000657', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-020, DSO-006] placement availability uses optimistic revisions and keeps identity immutable', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context('placement-create'); + const artifacts = new ArtifactService(repository); + const created = await artifacts.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'c'.repeat(64), + byteSize: 1, + mediaType: 'text/plain', + displayName: 'note.txt', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000002', + contentSha256: 'c'.repeat(64), + }, + }); + assert.equal(created.accepted, true); + const service = new ContentPlacementService(repository); + const unavailable = await service.setAvailability(context('placement-offline'), { + versionId, + placementId, + available: false, + expectedRevision: 1, + }); + assert.equal(unavailable.accepted, true); + if (!unavailable.accepted) return; + assert.equal(unavailable.value.available, false); + assert.equal(unavailable.value.revision, 2); + assert.equal(unavailable.value.opaqueReference, 'cloud-placement-000002'); + + const stale = await service.setAvailability(context('placement-stale'), { + versionId, + placementId, + available: true, + expectedRevision: 1, + }); + assert.deepEqual(stale, { accepted: false, code: 'REVISION_CONFLICT' }); +}); diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index 816dd1b7..b50896b7 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -77,6 +77,18 @@ function client( ), ); }, + findUnique(input) { + return Promise.resolve( + placements.find((candidate) => candidate.id === input.where.id) ?? null, + ); + }, + update(input) { + const current = placements.find((candidate) => candidate.id === input.where.id); + if (!current) throw new Error('fixture placement not found'); + const next = { ...current, ...input.data }; + placements[placements.indexOf(current)] = next; + return Promise.resolve(next); + }, }, evidenceReference: { create(input) { From 474843fdd4f5334c7d20792f4dab43602759688e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:09:47 +0700 Subject: [PATCH 04/74] feat(iae): model explicit artifact deletion authorization --- packages/domain/package.json | 4 + packages/domain/src/artifact-retention/v1.ts | 142 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../test/artifact-retention-v1.test.mjs | 52 +++++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 1 + 6 files changed, 203 insertions(+) create mode 100644 packages/domain/src/artifact-retention/v1.ts create mode 100644 packages/domain/test/artifact-retention-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index b2fe790f..ffacafd7 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -72,6 +72,10 @@ "types": "./src/artifact-governance/v1.ts", "import": "./dist/artifact-governance/v1.js" }, + "./artifact-retention/v1": { + "types": "./src/artifact-retention/v1.ts", + "import": "./dist/artifact-retention/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/artifact-retention/v1.ts b/packages/domain/src/artifact-retention/v1.ts new file mode 100644 index 00000000..6df42e82 --- /dev/null +++ b/packages/domain/src/artifact-retention/v1.ts @@ -0,0 +1,142 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { ArtifactRetentionEvaluationV1 } from '../artifact-governance/v1.js'; + +/** IAE-016, IAE-021: explicit, auditable deletion requests separate from byte erasure. */ +export const ARTIFACT_RETENTION_SCHEMA_VERSION_V1 = 1 as const; + +export type ArtifactDeletionStateV1 = + | 'REQUESTED' + | 'BLOCKED' + | 'AUTHORIZED' + | 'COMPLETED' + | 'CANCELLED'; + +export interface ArtifactDeletionRequestV1 { + readonly schemaVersion: typeof ARTIFACT_RETENTION_SCHEMA_VERSION_V1; + readonly requestId: StableIdentifierV1; + readonly artifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly requestedBy: StableIdentifierV1; + readonly requestedAt: StrictUtcTimestampV1; + readonly state: ArtifactDeletionStateV1; + readonly blockers: readonly string[]; + readonly authorizedAt?: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ArtifactRetentionErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_STATE' + | 'INVALID_REVISION' + | 'CROSS_SCOPE' + | 'RETENTION_BLOCKED' + | 'MFA_REQUIRED'; + +export type ArtifactRetentionResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactRetentionErrorCodeV1 }; + +function accepted(value: TValue): ArtifactRetentionResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactRetentionErrorCodeV1): ArtifactRetentionResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +export function createArtifactDeletionRequestV1(input: { + readonly requestId: unknown; + readonly artifactVersionId: unknown; + readonly tenantScope: unknown; + readonly requestedBy: unknown; + readonly requestedAt: unknown; +}): ArtifactRetentionResultV1 { + const requestId = identifier(input.requestId); + const artifactVersionId = identifier(input.artifactVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const requestedBy = identifier(input.requestedBy); + const requestedAt = timestamp(input.requestedAt); + if (!requestId || !artifactVersionId || !requestedBy) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!requestedAt) return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_RETENTION_SCHEMA_VERSION_V1, + requestId, + artifactVersionId, + tenantScope: tenantScope.value, + requestedBy, + requestedAt, + state: 'REQUESTED' as const, + blockers: Object.freeze([]), + revision: 1, + }), + ); +} + +export function authorizeArtifactDeletionV1( + request: ArtifactDeletionRequestV1, + evaluation: ArtifactRetentionEvaluationV1, + input: { + readonly tenantScope: unknown; + readonly approvedAt: unknown; + readonly mfaSatisfied: unknown; + }, +): ArtifactRetentionResultV1 { + const tenantScope = parseTenantScopeV1(input.tenantScope); + const approvedAt = timestamp(input.approvedAt); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!tenantScopesEqualV1(tenantScope.value, request.tenantScope)) return rejected('CROSS_SCOPE'); + if (!approvedAt || Date.parse(approvedAt) < Date.parse(request.requestedAt)) + return rejected('INVALID_TIMESTAMP'); + if (typeof input.mfaSatisfied !== 'boolean' || !input.mfaSatisfied) + return rejected('MFA_REQUIRED'); + if (!evaluation.eligible) return rejected('RETENTION_BLOCKED'); + if (request.state !== 'REQUESTED' && request.state !== 'BLOCKED') + return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + ...request, + state: 'AUTHORIZED' as const, + blockers: Object.freeze([]), + authorizedAt: approvedAt, + revision: request.revision + 1, + }), + ); +} + +export function blockArtifactDeletionV1( + request: ArtifactDeletionRequestV1, + evaluation: ArtifactRetentionEvaluationV1, +): ArtifactRetentionResultV1 { + if (request.state !== 'REQUESTED' && request.state !== 'BLOCKED') + return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + ...request, + state: 'BLOCKED' as const, + blockers: Object.freeze([...evaluation.blockers]), + revision: request.revision + (request.state === 'BLOCKED' ? 0 : 1), + }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 4f5b9a48..6d9e9706 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -3,6 +3,7 @@ export * from './audit/v1.js'; export * from './artifact/v1.js'; export * from './artifact-intake/v1.js'; export * from './artifact-governance/v1.js'; +export * from './artifact-retention/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; diff --git a/packages/domain/test/artifact-retention-v1.test.mjs b/packages/domain/test/artifact-retention-v1.test.mjs new file mode 100644 index 00000000..9959562f --- /dev/null +++ b/packages/domain/test/artifact-retention-v1.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + authorizeArtifactDeletionV1, + blockArtifactDeletionV1, + createArtifactDeletionRequestV1, +} from '../dist/artifact-retention/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000701', + workspaceId: '00000000-0000-4000-8000-000000000702', +}; +const base = { + requestId: '00000000-0000-4000-8000-000000000703', + artifactVersionId: '00000000-0000-4000-8000-000000000704', + tenantScope: scope, + requestedBy: '00000000-0000-4000-8000-000000000705', + requestedAt: '2026-01-03T00:00:00.000Z', +}; + +void test('[IAE-016, IAE-021] deletion authorization requires eligible retention and recent MFA', () => { + const request = createArtifactDeletionRequestV1(base); + assert.equal(request.accepted, true); + if (!request.accepted) return; + const blocked = blockArtifactDeletionV1(request.value, { + eligible: false, + blockers: ['LEGAL_HOLD'], + evaluatedAt: '2026-01-03T00:00:00.000Z', + }); + assert.deepEqual(blocked, { + accepted: true, + value: { ...request.value, state: 'BLOCKED', blockers: ['LEGAL_HOLD'], revision: 2 }, + }); + if (!blocked.accepted) return; + assert.deepEqual( + authorizeArtifactDeletionV1( + blocked.value, + { eligible: true, blockers: [], evaluatedAt: '2026-01-04T00:00:00.000Z' }, + { tenantScope: scope, approvedAt: '2026-01-04T00:00:00.000Z', mfaSatisfied: false }, + ), + { accepted: false, code: 'MFA_REQUIRED' }, + ); + const authorized = authorizeArtifactDeletionV1( + blocked.value, + { eligible: true, blockers: [], evaluatedAt: '2026-01-04T00:00:00.000Z' }, + { tenantScope: scope, approvedAt: '2026-01-04T00:00:00.000Z', mfaSatisfied: true }, + ); + assert.equal(authorized.accepted, true); + if (authorized.accepted) assert.equal(authorized.value.state, 'AUTHORIZED'); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 7bcfdb56..89d215d9 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -8,6 +8,7 @@ const [ artifact, artifactIntake, artifactGovernance, + artifactRetention, dataset, datasetGovernance, dataMode, @@ -30,6 +31,7 @@ const [ import('@databreeze/domain/artifact/v1'), import('@databreeze/domain/artifact-intake/v1'), import('@databreeze/domain/artifact-governance/v1'), + import('@databreeze/domain/artifact-retention/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), @@ -54,6 +56,7 @@ assert.equal(typeof authorization.createScopedAuthorizationEvaluatorV1, 'functio assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); +assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index f46407dc..bd12d868 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -26,6 +26,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact/v1', './artifact-intake/v1', './artifact-governance/v1', + './artifact-retention/v1', './dataset/v1', './dataset-governance/v1', './jobs/v1', From bf197f43c91227de5c051126fa96c5a8c716ca85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:11:56 +0700 Subject: [PATCH 05/74] feat(iae): add governed retention request service --- ...y-artifact-retention-repository.adapter.ts | 81 ++++++++++++++ .../iae/api/artifact-retention.controller.ts | 72 +++++++++++++ .../iae/api/artifact-retention.dto.ts | 61 +++++++++++ .../artifact-retention-repository.port.ts | 20 ++++ .../application/artifact-retention.service.ts | 102 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 13 +++ .../iae/artifact-retention.service.test.ts | 97 +++++++++++++++++ 7 files changed, 446 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts create mode 100644 services/api/src/features/iae/api/artifact-retention.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-retention.dto.ts create mode 100644 services/api/src/features/iae/application/artifact-retention-repository.port.ts create mode 100644 services/api/src/features/iae/application/artifact-retention.service.ts create mode 100644 services/api/test/features/iae/artifact-retention.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts new file mode 100644 index 00000000..73624c5e --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts @@ -0,0 +1,81 @@ +import { + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { ArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactRetentionRepositoryPortV1, + ArtifactRetentionTransactionPortV1, +} from '../application/artifact-retention-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(request: ArtifactDeletionRequestV1): ArtifactDeletionRequestV1 { + return Object.freeze({ + ...request, + tenantScope: Object.freeze({ ...request.tenantScope }), + blockers: Object.freeze([...request.blockers]), + }); +} + +export class InMemoryArtifactRetentionRepositoryAdapter + implements ArtifactRetentionRepositoryPortV1 +{ + private requests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save( + context: IamTenantContextV1, + request: ArtifactDeletionRequestV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.requests.get(request.requestId); + if (existing && JSON.stringify(existing) === JSON.stringify(request)) return; + if (existing) { + if (request.revision !== existing.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + existing.artifactVersionId !== request.artifactVersionId || + existing.requestedBy !== request.requestedBy || + existing.requestedAt !== request.requestedAt + ) + throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); + } + this.requests.set(request.requestId, clone(request)); + } + + public async find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise { + await Promise.resolve(); + const request = this.requests.get(requestId); + return request && visible(context.tenantScope, request.tenantScope) ? clone(request) : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactRetentionTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.requests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.requests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/api/artifact-retention.controller.ts b/services/api/src/features/iae/api/artifact-retention.controller.ts new file mode 100644 index 00000000..806e8e52 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-retention.controller.ts @@ -0,0 +1,72 @@ +import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { + ARTIFACT_RETENTION_REPOSITORY_PORT, + type ArtifactRetentionRepositoryPortV1, +} from '../application/artifact-retention-repository.port.js'; +import { ArtifactRetentionService } from '../application/artifact-retention.service.js'; +import { + AuthorizeArtifactDeletionRequestDto, + CreateArtifactDeletionRequestDto, +} from './artifact-retention.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1') +export class ArtifactRetentionController { + private readonly retention: ArtifactRetentionService; + + public constructor( + @Inject(ARTIFACT_RETENTION_REPOSITORY_PORT) requests: ArtifactRetentionRepositoryPortV1, + @Inject(ARTIFACT_REPOSITORY_PORT) artifacts: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.retention = new ArtifactRetentionService(requests, artifacts); + } + + @Post('artifact-versions/:versionId/deletion-requests') + @ApiOperation({ summary: 'Request governed deletion of an exact artifact version' }) + @ApiBody({ type: CreateArtifactDeletionRequestDto }) + async request( + @Req() request: unknown, + @Param('versionId') versionId: string, + @Body() input: CreateArtifactDeletionRequestDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.retention.request(context, { + requestId: input.requestId, + artifactVersionId: versionId, + tenantScope: context.tenantScope, + requestedBy: input.requestedBy, + requestedAt: input.requestedAt, + retention: input, + }); + } + + @Post('artifact-deletion-requests/:requestId/authorize') + @ApiOperation({ summary: 'Authorize an eligible deletion request after MFA step-up' }) + @ApiBody({ type: AuthorizeArtifactDeletionRequestDto }) + async authorize( + @Req() request: unknown, + @Param('requestId') requestId: string, + @Body() input: AuthorizeArtifactDeletionRequestDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.retention.authorize(context, { + requestId, + retention: input, + approvedAt: input.approvedAt, + mfaSatisfied: input.mfaSatisfied, + expectedRevision: input.expectedRevision, + }); + } +} diff --git a/services/api/src/features/iae/api/artifact-retention.dto.ts b/services/api/src/features/iae/api/artifact-retention.dto.ts new file mode 100644 index 00000000..be4ed98c --- /dev/null +++ b/services/api/src/features/iae/api/artifact-retention.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsISO8601, IsInt, IsUUID, Min } from 'class-validator'; + +export class RetentionEvaluationDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + evaluatedAt!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + workspaceRetentionUntil!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + resourceRetentionUntil!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + auditRetentionUntil!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + recoveryWindowUntil!: string; + + @ApiProperty() + @IsBoolean() + activeApproval!: boolean; + + @ApiProperty() + @IsBoolean() + legalHold!: boolean; +} + +export class CreateArtifactDeletionRequestDto extends RetentionEvaluationDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + requestId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + requestedBy!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + requestedAt!: string; +} + +export class AuthorizeArtifactDeletionRequestDto extends RetentionEvaluationDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + approvedAt!: string; + + @ApiProperty() + @IsBoolean() + mfaSatisfied!: boolean; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} diff --git a/services/api/src/features/iae/application/artifact-retention-repository.port.ts b/services/api/src/features/iae/application/artifact-retention-repository.port.ts new file mode 100644 index 00000000..10bb6a64 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-retention-repository.port.ts @@ -0,0 +1,20 @@ +import type { ArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_RETENTION_REPOSITORY_PORT = Symbol('ARTIFACT_RETENTION_REPOSITORY_PORT'); + +export interface ArtifactRetentionTransactionPortV1 { + save(context: IamTenantContextV1, request: ArtifactDeletionRequestV1): Promise; + find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise; +} + +export interface ArtifactRetentionRepositoryPortV1 extends ArtifactRetentionTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactRetentionTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/artifact-retention.service.ts b/services/api/src/features/iae/application/artifact-retention.service.ts new file mode 100644 index 00000000..1958e7b8 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-retention.service.ts @@ -0,0 +1,102 @@ +import { + authorizeArtifactDeletionV1, + blockArtifactDeletionV1, + createArtifactDeletionRequestV1, + type ArtifactDeletionRequestV1, + type ArtifactRetentionResultV1, +} from '@databreeze/domain/artifact-retention/v1'; +import { evaluateArtifactRetentionV1 } from '@databreeze/domain/artifact-governance/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; +import type { ArtifactRetentionRepositoryPortV1 } from './artifact-retention-repository.port.js'; + +export type ArtifactRetentionServiceErrorV1 = 'ARTIFACT_NOT_FOUND' | 'REQUEST_NOT_FOUND'; +export type ArtifactRetentionServiceResultV1 = + | ArtifactRetentionResultV1 + | { readonly accepted: false; readonly code: ArtifactRetentionServiceErrorV1 }; + +/** Keeps retention policy and deletion-request state in IAE; object erasure remains asynchronous. */ +export class ArtifactRetentionService { + public constructor( + private readonly requests: ArtifactRetentionRepositoryPortV1, + private readonly artifacts: ArtifactRepositoryPortV1, + ) {} + + public async request( + context: IamTenantContextV1, + input: Parameters[0] & { + readonly retention: Parameters[0]; + }, + ): Promise> { + const created = createArtifactDeletionRequestV1(input); + if (!created.accepted) return created; + const artifactVersionId = parseStableIdentifierV1(input.artifactVersionId); + if (!artifactVersionId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const artifact = await this.artifacts.findVersion(context, artifactVersionId.value); + if (!artifact) return Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + const evaluation = evaluateArtifactRetentionV1(input.retention); + if (!evaluation.accepted) + return Object.freeze({ + accepted: false as const, + code: + evaluation.code === 'INVALID_TIMESTAMP' + ? ('INVALID_TIMESTAMP' as const) + : ('INVALID_STATE' as const), + }); + const next = evaluation.value.eligible + ? created + : blockArtifactDeletionV1(created.value, evaluation.value); + if (!next.accepted) return next; + return this.requests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, next.value.requestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(next.value)) + return { accepted: true, value: existing }; + throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); + } + await transaction.save(context, next.value); + return next; + }); + } + + public async authorize( + context: IamTenantContextV1, + input: { + readonly requestId: unknown; + readonly retention: Parameters[0]; + readonly approvedAt: unknown; + readonly mfaSatisfied: unknown; + readonly expectedRevision?: unknown; + }, + ): Promise> { + const requestId = parseStableIdentifierV1(input.requestId); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const evaluation = evaluateArtifactRetentionV1(input.retention); + if (!evaluation.accepted) + return Object.freeze({ + accepted: false as const, + code: + evaluation.code === 'INVALID_TIMESTAMP' + ? ('INVALID_TIMESTAMP' as const) + : ('INVALID_STATE' as const), + }); + return this.requests.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, requestId.value); + if (!current) return Object.freeze({ accepted: false, code: 'REQUEST_NOT_FOUND' as const }); + if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) + return Object.freeze({ accepted: false, code: 'INVALID_REVISION' as const }); + const authorized = authorizeArtifactDeletionV1(current, evaluation.value, { + tenantScope: context.tenantScope, + approvedAt: input.approvedAt, + mfaSatisfied: input.mfaSatisfied, + }); + if (!authorized.accepted) return authorized; + await transaction.save(context, authorized.value); + return authorized; + }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index d1d52040..205b88cb 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -5,6 +5,7 @@ import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { ArtifactReadController } from './api/artifact-read.controller.js'; import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; import { ContentPlacementController } from './api/content-placement.controller.js'; +import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -12,6 +13,7 @@ import { } from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -29,6 +31,10 @@ import { ARTIFACT_LINEAGE_REPOSITORY_PORT, type ArtifactLineageRepositoryPortV1, } from './application/artifact-lineage-repository.port.js'; +import { + ARTIFACT_RETENTION_REPOSITORY_PORT, + type ArtifactRetentionRepositoryPortV1, +} from './application/artifact-retention-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -47,6 +53,7 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; + readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -62,6 +69,7 @@ export class IaeModule { ArtifactReadController, ArtifactLineageController, ContentPlacementController, + ArtifactRetentionController, ], providers: [ { @@ -85,6 +93,10 @@ export class IaeModule { useValue: options.artifactLineageRepository ?? new InMemoryArtifactLineageRepositoryAdapter(), }, + { + provide: ARTIFACT_RETENTION_REPOSITORY_PORT, + useValue: options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), @@ -98,6 +110,7 @@ export class IaeModule { ARTIFACT_INTAKE_REPOSITORY_PORT, ARTIFACT_REPOSITORY_PORT, ARTIFACT_LINEAGE_REPOSITORY_PORT, + ARTIFACT_RETENTION_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-retention.service.test.ts b/services/api/test/features/iae/artifact-retention.service.test.ts new file mode 100644 index 00000000..a5ad0348 --- /dev/null +++ b/services/api/test/features/iae/artifact-retention.service.test.ts @@ -0,0 +1,97 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactRetentionRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactRetentionService } from '../../../src/features/iae/application/artifact-retention.service.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000711'; +const workspaceId = '00000000-0000-4000-8000-000000000712'; +const artifactId = '00000000-0000-4000-8000-000000000713'; +const versionId = '00000000-0000-4000-8000-000000000714'; + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000715', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000716', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function retention(legalHold: boolean) { + return { + evaluatedAt: '2026-01-03T00:00:00.000Z', + workspaceRetentionUntil: '2025-12-01T00:00:00.000Z', + resourceRetentionUntil: '2025-12-01T00:00:00.000Z', + auditRetentionUntil: '2025-12-01T00:00:00.000Z', + recoveryWindowUntil: '2025-12-01T00:00:00.000Z', + activeApproval: false, + legalHold, + }; +} + +void test('[IAE-016, IAE-021] retention service preserves blocked requests and authorizes only after re-evaluation', async () => { + const artifacts = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context('retention-artifact'); + const artifactService = new ArtifactService(artifacts); + await artifactService.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'd'.repeat(64), + byteSize: 1, + mediaType: 'text/plain', + displayName: 'private.txt', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000717', + tenantScope: tenantContext.tenantScope, + kind: 'LOCAL', + opaqueReference: 'local-placement-000003', + contentSha256: 'd'.repeat(64), + }, + }); + const service = new ArtifactRetentionService( + new InMemoryArtifactRetentionRepositoryAdapter(), + artifacts, + ); + const request = await service.request(tenantContext, { + requestId: '00000000-0000-4000-8000-000000000718', + artifactVersionId: versionId, + tenantScope: tenantContext.tenantScope, + requestedBy: tenantContext.actorId, + requestedAt: '2026-01-03T00:00:00.000Z', + retention: retention(true), + }); + assert.equal(request.accepted, true); + if (!request.accepted) return; + assert.equal(request.value.state, 'BLOCKED'); + const stale = await service.authorize(tenantContext, { + requestId: request.value.requestId, + retention: retention(true), + approvedAt: '2026-01-04T00:00:00.000Z', + mfaSatisfied: true, + expectedRevision: request.value.revision, + }); + assert.deepEqual(stale, { accepted: false, code: 'RETENTION_BLOCKED' }); + const authorized = await service.authorize(tenantContext, { + requestId: request.value.requestId, + retention: retention(false), + approvedAt: '2026-01-04T00:00:00.000Z', + mfaSatisfied: true, + expectedRevision: request.value.revision, + }); + assert.equal(authorized.accepted, true); + if (authorized.accepted) assert.equal(authorized.value.state, 'AUTHORIZED'); +}); From c678bed96582956b30c40cbf45bbb293545d9f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:12:56 +0700 Subject: [PATCH 06/74] feat(iae): define verifiable artifact export manifests --- packages/domain/package.json | 4 + packages/domain/src/artifact-export/v1.ts | 159 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/artifact-export-v1.test.mjs | 58 +++++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 1 + 6 files changed, 226 insertions(+) create mode 100644 packages/domain/src/artifact-export/v1.ts create mode 100644 packages/domain/test/artifact-export-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index ffacafd7..12b5fb7b 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -76,6 +76,10 @@ "types": "./src/artifact-retention/v1.ts", "import": "./dist/artifact-retention/v1.js" }, + "./artifact-export/v1": { + "types": "./src/artifact-export/v1.ts", + "import": "./dist/artifact-export/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/artifact-export/v1.ts b/packages/domain/src/artifact-export/v1.ts new file mode 100644 index 00000000..316e95f6 --- /dev/null +++ b/packages/domain/src/artifact-export/v1.ts @@ -0,0 +1,159 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAE-018: independent verification manifest for governed artifact exports. */ +export const ARTIFACT_EXPORT_SCHEMA_VERSION_V1 = 1 as const; + +export type ExportApprovalStateV1 = 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + +export interface ArtifactExportEntryV1 { + readonly versionId: StableIdentifierV1; + readonly contentSha256: string; + readonly byteSize: number; + readonly evidenceIds: readonly StableIdentifierV1[]; + readonly processorVersions: readonly string[]; +} + +export interface ArtifactExportManifestV1 { + readonly schemaVersion: typeof ARTIFACT_EXPORT_SCHEMA_VERSION_V1; + readonly manifestId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly entries: readonly ArtifactExportEntryV1[]; + readonly approvalState: ExportApprovalStateV1; + readonly createdAt: StrictUtcTimestampV1; + readonly canonicalHash: string; +} + +export type ArtifactExportErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'CROSS_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_ENTRY' + | 'DUPLICATE_IDENTIFIER' + | 'INVALID_APPROVAL'; + +export type ArtifactExportResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactExportErrorCodeV1 }; + +function accepted(value: TValue): ArtifactExportResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactExportErrorCodeV1): ArtifactExportResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function text(input: unknown): string | undefined { + return typeof input === 'string' && + input.length > 0 && + input.length <= 128 && + !/\p{Cc}/u.test(input) + ? input.normalize('NFC').trim() + : undefined; +} + +export function createArtifactExportManifestV1(input: { + readonly manifestId: unknown; + readonly tenantScope: unknown; + readonly entries: unknown; + readonly approvalState: unknown; + readonly createdAt: unknown; + readonly canonicalHash: unknown; +}): ArtifactExportResultV1 { + const manifestId = identifier(input.manifestId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const createdAt = timestamp(input.createdAt); + const canonicalHash = + typeof input.canonicalHash === 'string' && /^[0-9a-f]{64}$/u.test(input.canonicalHash) + ? input.canonicalHash.toLowerCase() + : undefined; + if (!manifestId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (!canonicalHash) return rejected('INVALID_HASH'); + if (!['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED'].includes(input.approvalState as string)) + return rejected('INVALID_APPROVAL'); + if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > 1024) + return rejected('INVALID_ENTRY'); + const entries: ArtifactExportEntryV1[] = []; + for (const candidate of input.entries) { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) + return rejected('INVALID_ENTRY'); + const record = candidate as Record; + const versionId = identifier(record['versionId']); + const contentSha256 = + typeof record['contentSha256'] === 'string' && /^[0-9a-f]{64}$/u.test(record['contentSha256']) + ? record['contentSha256'].toLowerCase() + : undefined; + const byteSize = record['byteSize']; + const evidenceIds = Array.isArray(record['evidenceIds']) + ? record['evidenceIds'].map(identifier) + : undefined; + const processorVersions = Array.isArray(record['processorVersions']) + ? record['processorVersions'].map(text) + : undefined; + if ( + !versionId || + !contentSha256 || + typeof byteSize !== 'number' || + !Number.isSafeInteger(byteSize) || + byteSize < 0 || + !evidenceIds || + evidenceIds.some((value): value is undefined => value === undefined) || + !processorVersions || + processorVersions.some((value): value is undefined => value === undefined) + ) + return rejected('INVALID_ENTRY'); + entries.push( + Object.freeze({ + versionId, + contentSha256, + byteSize, + evidenceIds: Object.freeze(evidenceIds as StableIdentifierV1[]), + processorVersions: Object.freeze(processorVersions as string[]), + }), + ); + } + if (new Set(entries.map((entry) => entry.versionId)).size !== entries.length) + return rejected('DUPLICATE_IDENTIFIER'); + if (entries.some((entry) => entry.evidenceIds.some((evidenceId) => !evidenceId))) + return rejected('INVALID_ENTRY'); + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_EXPORT_SCHEMA_VERSION_V1, + manifestId, + tenantScope: tenantScope.value, + entries: Object.freeze(entries), + approvalState: input.approvalState as ExportApprovalStateV1, + createdAt, + canonicalHash, + }), + ); +} + +export function exportScopesEqualV1( + left: ArtifactExportManifestV1, + right: ArtifactExportManifestV1, +): boolean { + return tenantScopesEqualV1(left.tenantScope, right.tenantScope); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 6d9e9706..2034d52e 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -4,6 +4,7 @@ export * from './artifact/v1.js'; export * from './artifact-intake/v1.js'; export * from './artifact-governance/v1.js'; export * from './artifact-retention/v1.js'; +export * from './artifact-export/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; diff --git a/packages/domain/test/artifact-export-v1.test.mjs b/packages/domain/test/artifact-export-v1.test.mjs new file mode 100644 index 00000000..8e6cfb3b --- /dev/null +++ b/packages/domain/test/artifact-export-v1.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createArtifactExportManifestV1 } from '../dist/artifact-export/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000721', + workspaceId: '00000000-0000-4000-8000-000000000722', +}; + +void test('[IAE-018] export manifests preserve hashes, evidence references, and approval state', () => { + const result = createArtifactExportManifestV1({ + manifestId: '00000000-0000-4000-8000-000000000723', + tenantScope: scope, + entries: [ + { + versionId: '00000000-0000-4000-8000-000000000724', + contentSha256: 'a'.repeat(64), + byteSize: 10, + evidenceIds: ['00000000-0000-4000-8000-000000000725'], + processorVersions: ['spreadsheet-auditor@1'], + }, + ], + approvalState: 'APPROVED', + createdAt: '2026-01-03T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.entries[0].contentSha256, 'a'.repeat(64)); + assert.deepEqual( + createArtifactExportManifestV1({ + manifestId: '00000000-0000-4000-8000-000000000723', + tenantScope: scope, + entries: [ + { + versionId: '00000000-0000-4000-8000-000000000724', + contentSha256: 'a'.repeat(64), + byteSize: 10, + evidenceIds: [], + processorVersions: [], + }, + { + versionId: '00000000-0000-4000-8000-000000000724', + contentSha256: 'c'.repeat(64), + byteSize: 11, + evidenceIds: [], + processorVersions: [], + }, + ], + approvalState: 'PENDING', + createdAt: '2026-01-03T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 89d215d9..a57af3f5 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -9,6 +9,7 @@ const [ artifactIntake, artifactGovernance, artifactRetention, + artifactExport, dataset, datasetGovernance, dataMode, @@ -32,6 +33,7 @@ const [ import('@databreeze/domain/artifact-intake/v1'), import('@databreeze/domain/artifact-governance/v1'), import('@databreeze/domain/artifact-retention/v1'), + import('@databreeze/domain/artifact-export/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), @@ -57,6 +59,7 @@ assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); +assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index bd12d868..78400cdf 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -27,6 +27,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-intake/v1', './artifact-governance/v1', './artifact-retention/v1', + './artifact-export/v1', './dataset/v1', './dataset-governance/v1', './jobs/v1', From 33e2fe65da9f5b6443e019528ad92dd85b4b19ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:16:03 +0700 Subject: [PATCH 07/74] feat(iae): persist artifact export manifests --- ...mory-artifact-export-repository.adapter.ts | 78 ++++++++++++++ .../iae/api/artifact-export.controller.ts | 53 +++++++++ .../features/iae/api/artifact-export.dto.ts | 23 ++++ .../artifact-export-repository.port.ts | 20 ++++ .../application/artifact-export.service.ts | 101 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 17 ++- .../iae/artifact-export.service.test.ts | 92 ++++++++++++++++ 7 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts create mode 100644 services/api/src/features/iae/api/artifact-export.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-export.dto.ts create mode 100644 services/api/src/features/iae/application/artifact-export-repository.port.ts create mode 100644 services/api/src/features/iae/application/artifact-export.service.ts create mode 100644 services/api/test/features/iae/artifact-export.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts new file mode 100644 index 00000000..323a6399 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts @@ -0,0 +1,78 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { ArtifactExportManifestV1 } from '@databreeze/domain/artifact-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactExportRepositoryPortV1, + ArtifactExportTransactionPortV1, +} from '../application/artifact-export-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(manifest: ArtifactExportManifestV1): ArtifactExportManifestV1 { + return Object.freeze({ + ...manifest, + tenantScope: Object.freeze({ ...manifest.tenantScope }), + entries: Object.freeze( + manifest.entries.map((entry) => + Object.freeze({ + ...entry, + evidenceIds: Object.freeze([...entry.evidenceIds]), + processorVersions: Object.freeze([...entry.processorVersions]), + }), + ), + ), + }); +} + +export class InMemoryArtifactExportRepositoryAdapter implements ArtifactExportRepositoryPortV1 { + private manifests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save( + context: IamTenantContextV1, + manifest: ArtifactExportManifestV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.manifests.get(manifest.manifestId); + if (existing && JSON.stringify(existing) !== JSON.stringify(manifest)) + throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); + this.manifests.set(manifest.manifestId, clone(manifest)); + } + + public async find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise { + await Promise.resolve(); + const manifest = this.manifests.get(manifestId); + return manifest && visible(context.tenantScope, manifest.tenantScope) + ? clone(manifest) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactExportTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.manifests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.manifests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/api/artifact-export.controller.ts b/services/api/src/features/iae/api/artifact-export.controller.ts new file mode 100644 index 00000000..c1a44bfb --- /dev/null +++ b/services/api/src/features/iae/api/artifact-export.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ArtifactService } from '../application/artifact.service.js'; +import { + ARTIFACT_LINEAGE_REPOSITORY_PORT, + type ArtifactLineageRepositoryPortV1, +} from '../application/artifact-lineage-repository.port.js'; +import { + ARTIFACT_EXPORT_REPOSITORY_PORT, + type ArtifactExportRepositoryPortV1, +} from '../application/artifact-export-repository.port.js'; +import { ArtifactExportService } from '../application/artifact-export.service.js'; +import { CreateArtifactExportDto } from './artifact-export.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifacts/exports') +export class ArtifactExportController { + private readonly exports: ArtifactExportService; + + public constructor( + @Inject(ARTIFACT_EXPORT_REPOSITORY_PORT) manifests: ArtifactExportRepositoryPortV1, + @Inject(ARTIFACT_REPOSITORY_PORT) artifacts: ArtifactRepositoryPortV1, + @Inject(ARTIFACT_LINEAGE_REPOSITORY_PORT) lineage: ArtifactLineageRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.exports = new ArtifactExportService(manifests, new ArtifactService(artifacts), lineage); + } + + @Post() + @ApiOperation({ summary: 'Create an immutable artifact verification manifest' }) + @ApiBody({ type: CreateArtifactExportDto }) + async create(@Req() request: unknown, @Body() input: CreateArtifactExportDto): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.create(context, input); + } + + @Get(':manifestId') + @ApiOperation({ summary: 'Read an immutable artifact verification manifest' }) + async get(@Req() request: unknown, @Param('manifestId') manifestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.find(context, manifestId); + } +} diff --git a/services/api/src/features/iae/api/artifact-export.dto.ts b/services/api/src/features/iae/api/artifact-export.dto.ts new file mode 100644 index 00000000..4c0fa9a9 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-export.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsIn, IsISO8601, IsUUID, ArrayMaxSize, ArrayMinSize } from 'class-validator'; + +export class CreateArtifactExportDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + manifestId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(1024) + @IsUUID('4', { each: true }) + versionIds!: string[]; + + @ApiProperty({ enum: ['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED'] }) + @IsIn(['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED']) + approvalState!: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/iae/application/artifact-export-repository.port.ts b/services/api/src/features/iae/application/artifact-export-repository.port.ts new file mode 100644 index 00000000..8445f8a1 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-export-repository.port.ts @@ -0,0 +1,20 @@ +import type { ArtifactExportManifestV1 } from '@databreeze/domain/artifact-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_EXPORT_REPOSITORY_PORT = Symbol('ARTIFACT_EXPORT_REPOSITORY_PORT'); + +export interface ArtifactExportTransactionPortV1 { + save(context: IamTenantContextV1, manifest: ArtifactExportManifestV1): Promise; + find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise; +} + +export interface ArtifactExportRepositoryPortV1 extends ArtifactExportTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactExportTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/artifact-export.service.ts b/services/api/src/features/iae/application/artifact-export.service.ts new file mode 100644 index 00000000..eb149ef7 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-export.service.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; + +import { + createArtifactExportManifestV1, + type ArtifactExportManifestV1, + type ArtifactExportResultV1, +} from '@databreeze/domain/artifact-export/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import { ArtifactService } from './artifact.service.js'; +import type { ArtifactLineageRepositoryPortV1 } from './artifact-lineage-repository.port.js'; +import type { ArtifactExportRepositoryPortV1 } from './artifact-export-repository.port.js'; + +export type ArtifactExportServiceErrorV1 = 'ARTIFACT_NOT_FOUND'; +export type ArtifactExportServiceResultV1 = + | ArtifactExportResultV1 + | { readonly accepted: false; readonly code: ArtifactExportServiceErrorV1 }; + +/** Builds and stores an export manifest without copying protected source bytes. */ +export class ArtifactExportService { + public constructor( + private readonly manifests: ArtifactExportRepositoryPortV1, + private readonly artifacts: ArtifactService, + private readonly lineage: ArtifactLineageRepositoryPortV1, + ) {} + + public async create( + context: IamTenantContextV1, + input: { + readonly manifestId: unknown; + readonly versionIds: readonly unknown[]; + readonly approvalState: unknown; + readonly createdAt: unknown; + }, + ): Promise> { + const entries: Array<{ + readonly versionId: string; + readonly contentSha256: string; + readonly byteSize: number; + readonly evidenceIds: readonly string[]; + readonly processorVersions: readonly string[]; + }> = []; + for (const candidate of input.versionIds) { + const versionId = parseStableIdentifierV1(candidate); + if (!versionId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.artifacts.find(context, versionId.value); + if (!found.version) + return Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + const derivedLineage = await this.lineage.withTransaction(context, (transaction) => + transaction.findByDerived(context, versionId.value), + ); + entries.push({ + versionId: found.version.versionId, + contentSha256: found.version.contentSha256, + byteSize: found.version.byteSize, + evidenceIds: found.evidence.map((evidence) => evidence.evidenceId), + processorVersions: derivedLineage ? [derivedLineage.processorVersion] : [], + }); + } + const canonicalInput = JSON.stringify({ + tenantScope: context.tenantScope, + entries, + approvalState: input.approvalState, + }); + const canonicalHash = createHash('sha256').update(canonicalInput).digest('hex'); + const created = createArtifactExportManifestV1({ + manifestId: input.manifestId, + tenantScope: context.tenantScope, + entries, + approvalState: input.approvalState, + createdAt: input.createdAt, + canonicalHash, + }); + if (!created.accepted) return created; + return this.manifests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.manifestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + manifestIdInput: unknown, + ): Promise> { + const manifestId = parseStableIdentifierV1(manifestIdInput); + if (!manifestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.manifests.find(context, manifestId.value); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 205b88cb..6bf2a24e 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -6,6 +6,7 @@ import { ArtifactReadController } from './api/artifact-read.controller.js'; import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; import { ContentPlacementController } from './api/content-placement.controller.js'; import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; +import { ArtifactExportController } from './api/artifact-export.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -14,6 +15,7 @@ import { import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; +import { InMemoryArtifactExportRepositoryAdapter } from './adapter/in-memory-artifact-export-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -35,6 +37,10 @@ import { ARTIFACT_RETENTION_REPOSITORY_PORT, type ArtifactRetentionRepositoryPortV1, } from './application/artifact-retention-repository.port.js'; +import { + ARTIFACT_EXPORT_REPOSITORY_PORT, + type ArtifactExportRepositoryPortV1, +} from './application/artifact-export-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -54,6 +60,7 @@ export interface IaeModuleOptions { readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; + readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -70,6 +77,7 @@ export class IaeModule { ArtifactLineageController, ContentPlacementController, ArtifactRetentionController, + ArtifactExportController, ], providers: [ { @@ -95,7 +103,13 @@ export class IaeModule { }, { provide: ARTIFACT_RETENTION_REPOSITORY_PORT, - useValue: options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + useValue: + options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + }, + { + provide: ARTIFACT_EXPORT_REPOSITORY_PORT, + useValue: + options.artifactExportRepository ?? new InMemoryArtifactExportRepositoryAdapter(), }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, @@ -111,6 +125,7 @@ export class IaeModule { ARTIFACT_REPOSITORY_PORT, ARTIFACT_LINEAGE_REPOSITORY_PORT, ARTIFACT_RETENTION_REPOSITORY_PORT, + ARTIFACT_EXPORT_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-export.service.test.ts b/services/api/test/features/iae/artifact-export.service.test.ts new file mode 100644 index 00000000..ea057ef4 --- /dev/null +++ b/services/api/test/features/iae/artifact-export.service.test.ts @@ -0,0 +1,92 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactExportRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-export-repository.adapter.js'; +import { InMemoryArtifactLineageRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactExportService } from '../../../src/features/iae/application/artifact-export.service.js'; +import { ArtifactGovernanceService } from '../../../src/features/iae/application/artifact-governance.service.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000731'; +const workspaceId = '00000000-0000-4000-8000-000000000732'; +const artifactId = '00000000-0000-4000-8000-000000000733'; +const versionId = '00000000-0000-4000-8000-000000000734'; + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000735', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000736', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-018] export service creates an idempotent manifest with exact evidence and lineage', async () => { + const artifacts = new InMemoryArtifactRepositoryAdapter(); + const lineage = new InMemoryArtifactLineageRepositoryAdapter(); + const tenantContext = context('export-artifact'); + await new ArtifactService(artifacts).register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'GENERATED', + dataMode: 'Hybrid', + contentSha256: 'e'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'derived.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000737', + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000004', + contentSha256: 'e'.repeat(64), + }, + evidence: { + evidenceId: '00000000-0000-4000-8000-000000000738', + tenantScope: tenantContext.tenantScope, + coordinate: { kind: 'ROW', row: 1 }, + }, + }); + const governance = new ArtifactGovernanceService(lineage); + await governance.registerLineage(tenantContext, { + lineageId: '00000000-0000-4000-8000-000000000739', + derivedArtifactVersionId: versionId, + tenantScope: tenantContext.tenantScope, + sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000740'], + sourceTenantScopes: [tenantContext.tenantScope], + processorVersion: 'spreadsheet-auditor@1', + coordinateLineage: [], + }); + const service = new ArtifactExportService( + new InMemoryArtifactExportRepositoryAdapter(), + new ArtifactService(artifacts), + lineage, + ); + const created = await service.create(tenantContext, { + manifestId: '00000000-0000-4000-8000-000000000741', + versionIds: [versionId], + approvalState: 'APPROVED', + createdAt: '2026-01-03T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.deepEqual(created.value.entries[0]?.evidenceIds, ['00000000-0000-4000-8000-000000000738']); + assert.deepEqual(created.value.entries[0]?.processorVersions, ['spreadsheet-auditor@1']); + const repeated = await service.create(tenantContext, { + manifestId: '00000000-0000-4000-8000-000000000741', + versionIds: [versionId], + approvalState: 'APPROVED', + createdAt: '2026-01-03T00:00:00.000Z', + }); + assert.deepEqual(repeated, created); +}); From 98bcb59072148d0449665f921a2900241fb5797e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:17:40 +0700 Subject: [PATCH 08/74] feat(dsm): expose governed dataset publication APIs --- .../dsm/api/governed-dataset.controller.ts | 71 +++++++++++++++- .../features/dsm/api/governed-dataset.dto.ts | 10 +++ .../application/governed-dataset.service.ts | 12 +++ .../dsm/governed-dataset.controller.test.ts | 83 +++++++++++++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 services/api/test/features/dsm/governed-dataset.controller.test.ts diff --git a/services/api/src/features/dsm/api/governed-dataset.controller.ts b/services/api/src/features/dsm/api/governed-dataset.controller.ts index 976d8e03..3247f46b 100644 --- a/services/api/src/features/dsm/api/governed-dataset.controller.ts +++ b/services/api/src/features/dsm/api/governed-dataset.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -7,7 +7,7 @@ import { type GovernedDatasetRepositoryPortV1, } from '../application/governed-dataset-repository.port.js'; import { GovernedDatasetService } from '../application/governed-dataset.service.js'; -import { CreateGovernedDatasetDto } from './governed-dataset.dto.js'; +import { CreateGovernedDatasetDto, PublishGovernedDatasetDto } from './governed-dataset.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -50,4 +50,71 @@ export class GovernedDatasetController { if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.datasets.list(context, datasetId.value); } + + @Get(':datasetId/versions/:versionId') + @ApiOperation({ summary: 'Read one exact immutable governed dataset definition' }) + async getVersion( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.datasets.find(context, versionId.value); + if (!result.accepted || result.value.datasetId !== datasetId.value) + return { accepted: false, code: 'VERSION_NOT_FOUND' as const }; + return result; + } + + @Post(':datasetId/versions/:versionId/publish') + @HttpCode(200) + @ApiOperation({ summary: 'Publish a governed dataset definition as a new immutable version' }) + @ApiBody({ type: PublishGovernedDatasetDto }) + async publish( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + @Body() input: PublishGovernedDatasetDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const current = await this.datasets.find(context, versionId.value); + if (!current.accepted || current.value.datasetId !== datasetId.value) + return { accepted: false, code: 'VERSION_NOT_FOUND' as const }; + return this.datasets.publish(context, versionId.value, input.nextVersionId, input.publishedAt); + } + + @Get(':datasetId/compatibility') + @ApiOperation({ summary: 'Classify compatibility between two exact schema versions' }) + async compare( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Query('previousVersionId') previousVersionIdInput: string, + @Query('nextVersionId') nextVersionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const previousVersionId = parseStableIdentifierV1(previousVersionIdInput); + const nextVersionId = parseStableIdentifierV1(nextVersionIdInput); + if (!datasetId.accepted || !previousVersionId.accepted || !nextVersionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const [previous, next] = await Promise.all([ + this.datasets.find(context, previousVersionId.value), + this.datasets.find(context, nextVersionId.value), + ]); + if ( + !previous.accepted || + !next.accepted || + previous.value.datasetId !== datasetId.value || + next.value.datasetId !== datasetId.value + ) + return { accepted: false, code: 'VERSION_NOT_FOUND' as const }; + return this.datasets.compare(context, previousVersionId.value, nextVersionId.value); + } } diff --git a/services/api/src/features/dsm/api/governed-dataset.dto.ts b/services/api/src/features/dsm/api/governed-dataset.dto.ts index f5152f27..44093518 100644 --- a/services/api/src/features/dsm/api/governed-dataset.dto.ts +++ b/services/api/src/features/dsm/api/governed-dataset.dto.ts @@ -96,3 +96,13 @@ export class CreateGovernedDatasetDto { @MaxLength(64) canonicalHash!: string; } + +export class PublishGovernedDatasetDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + nextVersionId!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + publishedAt!: string; +} diff --git a/services/api/src/features/dsm/application/governed-dataset.service.ts b/services/api/src/features/dsm/application/governed-dataset.service.ts index 7ec1903b..8363f5d8 100644 --- a/services/api/src/features/dsm/application/governed-dataset.service.ts +++ b/services/api/src/features/dsm/application/governed-dataset.service.ts @@ -78,4 +78,16 @@ export class GovernedDatasetService { transaction.list(context, datasetId), ); } + + public async find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const definition = await transaction.find(context, versionId); + return definition + ? Object.freeze({ accepted: true as const, value: definition }) + : Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + }); + } } diff --git a/services/api/test/features/dsm/governed-dataset.controller.test.ts b/services/api/test/features/dsm/governed-dataset.controller.test.ts new file mode 100644 index 00000000..f0cea603 --- /dev/null +++ b/services/api/test/features/dsm/governed-dataset.controller.test.ts @@ -0,0 +1,83 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryGovernedDatasetRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.js'; +import { GovernedDatasetService } from '../../../src/features/dsm/application/governed-dataset.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000751'; +const workspaceId = '00000000-0000-4000-8000-000000000752'; +const datasetId = '00000000-0000-4000-8000-000000000753'; +const versionId = '00000000-0000-4000-8000-000000000754'; +const publishedVersionId = '00000000-0000-4000-8000-000000000755'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000756', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000757', + idempotencyKey: 'dataset-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-005, DSM-006, DSM-018, DSM-021] governed dataset HTTP surfaces publish and compare immutable versions', async () => { + const repository = new InMemoryGovernedDatasetRepositoryAdapter(); + const tenantContext = context(); + const service = new GovernedDatasetService(repository); + const created = await service.create(tenantContext, { + datasetId, + versionId, + tenantScope: tenantContext.tenantScope, + name: 'Orders', + fields: [ + { + fieldId: '00000000-0000-4000-8000-000000000758', + name: 'amount', + type: 'DECIMAL', + nullable: true, + }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + governedDatasetRepository: repository, + requestTenantContext, + }); + try { + const published = await app.inject({ + method: 'POST', + url: `/v1/datasets/${datasetId}/versions/${versionId}/publish`, + payload: { + nextVersionId: publishedVersionId, + publishedAt: '2026-01-01T00:01:00.000Z', + }, + }); + assert.equal(published.statusCode, 200); + assert.equal(published.json().value.status, 'PUBLISHED'); + const read = await app.inject({ + method: 'GET', + url: `/v1/datasets/${datasetId}/versions/${publishedVersionId}`, + }); + assert.equal(read.statusCode, 200); + assert.equal(read.json().value.versionId, publishedVersionId); + const comparison = await app.inject({ + method: 'GET', + url: `/v1/datasets/${datasetId}/compatibility?previousVersionId=${versionId}&nextVersionId=${publishedVersionId}`, + }); + assert.equal(comparison.statusCode, 200); + assert.deepEqual(comparison.json(), { accepted: true, value: 'ADDITIVE_COMPATIBLE' }); + } finally { + await app.close(); + } +}); From aef376c591dc89f6d96d87fef9e231802a234cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:18:53 +0700 Subject: [PATCH 09/74] feat(dsm): expose mapping publication endpoint --- .../features/dsm/api/mapping.controller.ts | 22 +++++- .../api/src/features/dsm/api/mapping.dto.ts | 10 +++ .../features/dsm/mapping.controller.test.ts | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 services/api/test/features/dsm/mapping.controller.test.ts diff --git a/services/api/src/features/dsm/api/mapping.controller.ts b/services/api/src/features/dsm/api/mapping.controller.ts index bc142d38..76a84ef6 100644 --- a/services/api/src/features/dsm/api/mapping.controller.ts +++ b/services/api/src/features/dsm/api/mapping.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -7,7 +7,7 @@ import { type MappingRepositoryPortV1, } from '../application/mapping-repository.port.js'; import { MappingService } from '../application/mapping.service.js'; -import { CreateMappingDto } from './mapping.dto.js'; +import { CreateMappingDto, PublishDefinitionDto } from './mapping.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -55,4 +55,22 @@ export class MappingController { if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.mappings.list(context, datasetId.value); } + + @Post(':versionId/publish') + @HttpCode(200) + @ApiOperation({ summary: 'Publish a mapping definition as a new immutable version' }) + @ApiBody({ type: PublishDefinitionDto }) + async publish( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + @Body() input: PublishDefinitionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.mappings.publish(context, versionId.value, input.nextVersionId, input.publishedAt); + } } diff --git a/services/api/src/features/dsm/api/mapping.dto.ts b/services/api/src/features/dsm/api/mapping.dto.ts index 4b087f02..3b613e6a 100644 --- a/services/api/src/features/dsm/api/mapping.dto.ts +++ b/services/api/src/features/dsm/api/mapping.dto.ts @@ -95,3 +95,13 @@ export class CreateRuleSetDto { @MaxLength(64) canonicalHash!: string; } + +export class PublishDefinitionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + nextVersionId!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + publishedAt!: string; +} diff --git a/services/api/test/features/dsm/mapping.controller.test.ts b/services/api/test/features/dsm/mapping.controller.test.ts new file mode 100644 index 00000000..5646ba24 --- /dev/null +++ b/services/api/test/features/dsm/mapping.controller.test.ts @@ -0,0 +1,68 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryMappingRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-mapping-repository.adapter.js'; +import { MappingService } from '../../../src/features/dsm/application/mapping.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000761'; +const workspaceId = '00000000-0000-4000-8000-000000000762'; +const datasetId = '00000000-0000-4000-8000-000000000763'; +const versionId = '00000000-0000-4000-8000-000000000764'; +const nextVersionId = '00000000-0000-4000-8000-000000000765'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000766', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000767', + idempotencyKey: 'mapping-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-009, DSM-010, DSM-021] mapping publication is exposed as an immutable version transition', async () => { + const repository = new InMemoryMappingRepositoryAdapter(); + const tenantContext = context(); + const service = new MappingService(repository); + const created = await service.create(tenantContext, { + datasetId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceSchemaVersionId: '00000000-0000-4000-8000-000000000768', + targetSchemaVersionId: '00000000-0000-4000-8000-000000000769', + steps: [ + { + sourceFieldId: '00000000-0000-4000-8000-000000000770', + targetFieldId: '00000000-0000-4000-8000-000000000771', + transform: 'TRIM', + }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + mappingRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/datasets/${datasetId}/mappings/${versionId}/publish`, + payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, + }); + assert.equal(response.statusCode, 200); + assert.equal(response.json().value.status, 'PUBLISHED'); + } finally { + await app.close(); + } +}); From 2269787dfb74ef389f8eb3d3fb403e5ae253e40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:18:53 +0700 Subject: [PATCH 10/74] feat(dsm): expose rule-set publication endpoint --- .../features/dsm/api/rule-set.controller.ts | 22 +++++- .../features/dsm/rule-set.controller.test.ts | 69 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 services/api/test/features/dsm/rule-set.controller.test.ts diff --git a/services/api/src/features/dsm/api/rule-set.controller.ts b/services/api/src/features/dsm/api/rule-set.controller.ts index dcf3187e..cf813372 100644 --- a/services/api/src/features/dsm/api/rule-set.controller.ts +++ b/services/api/src/features/dsm/api/rule-set.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -7,7 +7,7 @@ import { type RuleSetRepositoryPortV1, } from '../application/rule-set-repository.port.js'; import { RuleSetService } from '../application/rule-set.service.js'; -import { CreateRuleSetDto } from './mapping.dto.js'; +import { CreateRuleSetDto, PublishDefinitionDto } from './mapping.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -55,4 +55,22 @@ export class RuleSetController { if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.ruleSets.list(context, datasetId.value); } + + @Post(':versionId/publish') + @HttpCode(200) + @ApiOperation({ summary: 'Publish a quality rule set as a new immutable version' }) + @ApiBody({ type: PublishDefinitionDto }) + async publish( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + @Body() input: PublishDefinitionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.ruleSets.publish(context, versionId.value, input.nextVersionId, input.publishedAt); + } } diff --git a/services/api/test/features/dsm/rule-set.controller.test.ts b/services/api/test/features/dsm/rule-set.controller.test.ts new file mode 100644 index 00000000..b0918f65 --- /dev/null +++ b/services/api/test/features/dsm/rule-set.controller.test.ts @@ -0,0 +1,69 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryRuleSetRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-rule-set-repository.adapter.js'; +import { RuleSetService } from '../../../src/features/dsm/application/rule-set.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000781'; +const workspaceId = '00000000-0000-4000-8000-000000000782'; +const datasetId = '00000000-0000-4000-8000-000000000783'; +const versionId = '00000000-0000-4000-8000-000000000784'; +const nextVersionId = '00000000-0000-4000-8000-000000000785'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000786', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000787', + idempotencyKey: 'rule-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-008, DSM-010, DSM-021] rule-set publication preserves typed deterministic rules', async () => { + const repository = new InMemoryRuleSetRepositoryAdapter(); + const tenantContext = context(); + const service = new RuleSetService(repository); + const created = await service.create(tenantContext, { + datasetId, + versionId, + tenantScope: tenantContext.tenantScope, + schemaVersionId: '00000000-0000-4000-8000-000000000788', + rules: [ + { + ruleId: '00000000-0000-4000-8000-000000000789', + fieldId: '00000000-0000-4000-8000-000000000790', + kind: 'REQUIRED', + severity: 'ERROR', + parameters: {}, + }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + ruleSetRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/datasets/${datasetId}/rules/${versionId}/publish`, + payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, + }); + assert.equal(response.statusCode, 200); + assert.equal(response.json().value.status, 'PUBLISHED'); + } finally { + await app.close(); + } +}); From 66d2a11849989a0f2e594cc6bb7a29d1399f0737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:19:47 +0700 Subject: [PATCH 11/74] feat(dsm): expose reference entity version history --- .../dsm/api/reference-entity.controller.ts | 30 +++++++++ .../application/reference-entity.service.ts | 12 ++++ .../dsm/reference-entity.controller.test.ts | 67 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 services/api/test/features/dsm/reference-entity.controller.test.ts diff --git a/services/api/src/features/dsm/api/reference-entity.controller.ts b/services/api/src/features/dsm/api/reference-entity.controller.ts index 1c6fcbfc..06aa3b33 100644 --- a/services/api/src/features/dsm/api/reference-entity.controller.ts +++ b/services/api/src/features/dsm/api/reference-entity.controller.ts @@ -50,4 +50,34 @@ export class ReferenceEntityController { if (!entityId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.entities.listVersions(context, entityId.value); } + + @Get(':entityId/versions/:versionId') + @ApiOperation({ summary: 'Read one exact immutable business-party version' }) + async getVersion( + @Req() request: unknown, + @Param('entityId') entityIdInput: string, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const entityId = parseStableIdentifierV1(entityIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!entityId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.entities.findVersion(context, versionId.value); + if (!result.accepted || result.value.entityId !== entityId.value) + return { accepted: false, code: 'ENTITY_NOT_FOUND' as const }; + return result; + } + + @Get(':entityId/resolutions') + @ApiOperation({ summary: 'List immutable merge and resolution history' }) + async resolutions( + @Req() request: unknown, + @Param('entityId') entityIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const entityId = parseStableIdentifierV1(entityIdInput); + if (!entityId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.entities.listResolutions(context, entityId.value); + } } diff --git a/services/api/src/features/dsm/application/reference-entity.service.ts b/services/api/src/features/dsm/application/reference-entity.service.ts index 249402f2..cdcbf381 100644 --- a/services/api/src/features/dsm/application/reference-entity.service.ts +++ b/services/api/src/features/dsm/application/reference-entity.service.ts @@ -83,6 +83,18 @@ export class ReferenceEntityService { ); } + public async findVersion( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const version = await transaction.findVersion(context, versionId); + return version + ? Object.freeze({ accepted: true as const, value: version }) + : Object.freeze({ accepted: false as const, code: 'ENTITY_NOT_FOUND' as const }); + }); + } + public async listResolutions( context: IamTenantContextV1, entityId: StableIdentifierV1, diff --git a/services/api/test/features/dsm/reference-entity.controller.test.ts b/services/api/test/features/dsm/reference-entity.controller.test.ts new file mode 100644 index 00000000..9748a4e7 --- /dev/null +++ b/services/api/test/features/dsm/reference-entity.controller.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryReferenceEntityRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.js'; +import { ReferenceEntityService } from '../../../src/features/dsm/application/reference-entity.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000791'; +const workspaceId = '00000000-0000-4000-8000-000000000792'; +const entityId = '00000000-0000-4000-8000-000000000793'; +const versionId = '00000000-0000-4000-8000-000000000794'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000795', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000796', + idempotencyKey: 'reference-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-025, DSM-026, DSM-027] reference entity API exposes exact versions and resolution history', async () => { + const repository = new InMemoryReferenceEntityRepositoryAdapter(); + const tenantContext = context(); + const service = new ReferenceEntityService(repository); + const created = await service.create(tenantContext, { + entityId, + versionId, + tenantScope: tenantContext.tenantScope, + displayName: 'Công ty Ánh Dương', + roles: ['SUPPLIER'], + aliases: ['Anh Duong'], + externalIdentifiers: [{ namespace: 'tax', value: '0101234567' }], + canonicalHash: 'a'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + referenceEntityRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'GET', + url: `/v1/reference-entities/${entityId}/versions/${versionId}`, + }); + assert.equal(response.statusCode, 200); + assert.equal(response.json().value.displayName, 'Công ty Ánh Dương'); + const resolutions = await app.inject({ + method: 'GET', + url: `/v1/reference-entities/${entityId}/resolutions`, + }); + assert.equal(resolutions.statusCode, 200); + assert.deepEqual(resolutions.json(), []); + } finally { + await app.close(); + } +}); From f23dc51ff4b3033f3b78216a6f56fb8929e6d470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:21:10 +0700 Subject: [PATCH 12/74] feat(dsm): persist immutable dataset result manifests --- ...mory-dataset-version-repository.adapter.ts | 67 +++++++++++++++++++ .../dsm/api/dataset-version.controller.ts | 53 +++++++++++++++ .../features/dsm/api/dataset-version.dto.ts | 62 +++++++++++++++++ .../dataset-version-repository.port.ts | 20 ++++++ .../application/dataset-version.service.ts | 45 +++++++++++++ services/api/src/features/dsm/dsm.module.ts | 13 ++++ .../dsm/dataset-version.controller.test.ts | 63 +++++++++++++++++ 7 files changed, 323 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts create mode 100644 services/api/src/features/dsm/api/dataset-version.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-version.dto.ts create mode 100644 services/api/src/features/dsm/application/dataset-version-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-version.service.ts create mode 100644 services/api/test/features/dsm/dataset-version.controller.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts new file mode 100644 index 00000000..6517e8d1 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts @@ -0,0 +1,67 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetVersionRepositoryPortV1, + DatasetVersionTransactionPortV1, +} from '../application/dataset-version-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(version: DatasetVersionManifestV1): DatasetVersionManifestV1 { + return Object.freeze({ + ...version, + tenantScope: Object.freeze({ ...version.tenantScope }), + inputArtifactVersionIds: Object.freeze([...version.inputArtifactVersionIds]), + }); +} + +export class InMemoryDatasetVersionRepositoryAdapter implements DatasetVersionRepositoryPortV1 { + private versions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.versions.get(version.versionId); + if (existing && JSON.stringify(existing) !== JSON.stringify(version)) + throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); + this.versions.set(version.versionId, clone(version)); + } + + public async find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise { + await Promise.resolve(); + const version = this.versions.get(versionId); + return version && visible(context.tenantScope, version.tenantScope) + ? clone(version) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetVersionTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.versions); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.versions = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/api/dataset-version.controller.ts b/services/api/src/features/dsm/api/dataset-version.controller.ts new file mode 100644 index 00000000..87ce6576 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-version.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + DATASET_VERSION_REPOSITORY_PORT, + type DatasetVersionRepositoryPortV1, +} from '../application/dataset-version-repository.port.js'; +import { DatasetVersionService } from '../application/dataset-version.service.js'; +import { RegisterDatasetVersionDto } from './dataset-version.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-versions') +export class DatasetVersionController { + private readonly versions: DatasetVersionService; + + public constructor( + @Inject(DATASET_VERSION_REPOSITORY_PORT) repository: DatasetVersionRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.versions = new DatasetVersionService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable dataset result manifest' }) + @ApiBody({ type: RegisterDatasetVersionDto }) + async register( + @Req() request: unknown, + @Body() input: RegisterDatasetVersionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.versions.register(context, { + ...input, + tenantScope: context.tenantScope, + contentFingerprint: input.contentFingerprint, + lineageManifestHash: input.lineageManifestHash, + }); + } + + @Get(':versionId') + @ApiOperation({ summary: 'Read an exact immutable dataset result manifest' }) + async get(@Req() request: unknown, @Param('versionId') versionIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.versions.find(context, versionId.value); + } +} diff --git a/services/api/src/features/dsm/api/dataset-version.dto.ts b/services/api/src/features/dsm/api/dataset-version.dto.ts new file mode 100644 index 00000000..4c6b80b5 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-version.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsISO8601, + IsUUID, + Max, + Min, + MinLength, +} from 'class-validator'; + +export class RegisterDatasetVersionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetId!: string; + + @ApiProperty({ format: 'uuid', type: [String] }) + @IsArray() + @ArrayMaxSize(1024) + @IsUUID('4', { each: true }) + inputArtifactVersionIds!: string[]; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + schemaVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + mappingVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleSetVersionId!: string; + + @ApiProperty({ minLength: 1, maxLength: 128 }) + @MinLength(1) + engineBuild!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsUUID() + versionId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @MinLength(64) + contentFingerprint!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCount!: number; + + @ApiProperty({ enum: ['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'] }) + @IsIn(['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE']) + qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @MinLength(64) + lineageManifestHash!: string; +} diff --git a/services/api/src/features/dsm/application/dataset-version-repository.port.ts b/services/api/src/features/dsm/application/dataset-version-repository.port.ts new file mode 100644 index 00000000..2acc2085 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-version-repository.port.ts @@ -0,0 +1,20 @@ +import type { DatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_VERSION_REPOSITORY_PORT = Symbol('DATASET_VERSION_REPOSITORY_PORT'); + +export interface DatasetVersionTransactionPortV1 { + save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise; + find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise; +} + +export interface DatasetVersionRepositoryPortV1 extends DatasetVersionTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetVersionTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-version.service.ts b/services/api/src/features/dsm/application/dataset-version.service.ts new file mode 100644 index 00000000..e331f087 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-version.service.ts @@ -0,0 +1,45 @@ +import { + createDatasetVersionManifestV1, + type DatasetVersionManifestV1, + type DatasetGovernanceResultV1, +} from '@databreeze/domain/dataset-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetVersionRepositoryPortV1 } from './dataset-version-repository.port.js'; + +export type DatasetVersionServiceErrorV1 = 'VERSION_NOT_FOUND'; +export type DatasetVersionServiceResultV1 = + | DatasetGovernanceResultV1 + | { readonly accepted: false; readonly code: DatasetVersionServiceErrorV1 }; + +export class DatasetVersionService { + public constructor(private readonly repository: DatasetVersionRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createDatasetVersionManifestV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.versionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise> { + const found = await this.repository.find(context, versionId); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 3bdb53f6..2a15d059 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -4,6 +4,7 @@ import { GovernedDatasetController } from './api/governed-dataset.controller.js' import { MappingController } from './api/mapping.controller.js'; import { ReferenceEntityController } from './api/reference-entity.controller.js'; import { RuleSetController } from './api/rule-set.controller.js'; +import { DatasetVersionController } from './api/dataset-version.controller.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -20,6 +21,7 @@ import { type ReferenceEntityDatabaseClientV1, } from './adapter/prisma-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from './adapter/in-memory-dataset-version-repository.adapter.js'; import { PrismaRuleSetRepositoryAdapter, type RuleSetDatabaseClientV1, @@ -40,6 +42,10 @@ import { RULE_SET_REPOSITORY_PORT, type RuleSetRepositoryPortV1, } from './application/rule-set-repository.port.js'; +import { + DATASET_VERSION_REPOSITORY_PORT, + type DatasetVersionRepositoryPortV1, +} from './application/dataset-version-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -59,6 +65,7 @@ export interface DsmModuleOptions { readonly referenceEntityRepository?: ReferenceEntityRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly referenceEntityDatabase?: ReferenceEntityDatabaseClientV1; + readonly datasetVersionRepository?: DatasetVersionRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -72,6 +79,7 @@ export class DsmModule { MappingController, RuleSetController, ReferenceEntityController, + DatasetVersionController, ], providers: [ { @@ -106,6 +114,11 @@ export class DsmModule { ? new InMemoryReferenceEntityRepositoryAdapter() : new PrismaReferenceEntityRepositoryAdapter(options.referenceEntityDatabase)), }, + { + provide: DATASET_VERSION_REPOSITORY_PORT, + useValue: + options.datasetVersionRepository ?? new InMemoryDatasetVersionRepositoryAdapter(), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts new file mode 100644 index 00000000..fbaba935 --- /dev/null +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -0,0 +1,63 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000801'; +const workspaceId = '00000000-0000-4000-8000-000000000802'; +const datasetId = '00000000-0000-4000-8000-000000000803'; +const versionId = '00000000-0000-4000-8000-000000000804'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000805', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000806', + idempotencyKey: 'dataset-version-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable and exact-input bound', async () => { + const repository = new InMemoryDatasetVersionRepositoryAdapter(); + const tenantContext = context(); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetVersionRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-versions', + payload: { + versionId, + datasetId, + inputArtifactVersionIds: ['00000000-0000-4000-8000-000000000807'], + schemaVersionId: '00000000-0000-4000-8000-000000000808', + mappingVersionId: '00000000-0000-4000-8000-000000000809', + ruleSetVersionId: '00000000-0000-4000-8000-000000000810', + engineBuild: 'engine@1', + contentFingerprint: 'a'.repeat(64), + rowCount: 42, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }, + }); + assert.equal(response.statusCode, 201); + assert.equal(response.json().value.rowCount, 42); + const read = await app.inject({ method: 'GET', url: `/v1/dataset-versions/${versionId}` }); + assert.equal(read.statusCode, 200); + assert.equal(read.json().value.contentFingerprint, 'a'.repeat(64)); + } finally { + await app.close(); + } +}); From b36f66d2dd5808f1f54d1957aa265672e05f5c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:24:45 +0700 Subject: [PATCH 13/74] feat(iae): add retention and export persistence schema --- .../migration.sql | 32 ++++++++++++++++ services/api/prisma/schema/iae.prisma | 38 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql diff --git a/services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql b/services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql new file mode 100644 index 00000000..04588bb6 --- /dev/null +++ b/services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql @@ -0,0 +1,32 @@ +-- IAE-016, IAE-018, IAE-021: durable retention requests and verification manifests. +CREATE TABLE "iae"."artifact_deletion_requests" ( + "id" UUID NOT NULL, + "artifact_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "requested_by" UUID NOT NULL, + "requested_at" TIMESTAMPTZ(6) NOT NULL, + "state" VARCHAR(16) NOT NULL, + "blockers" JSONB NOT NULL, + "authorized_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "artifact_deletion_requests_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "artifact_deletion_requests_artifact_idx" ON "iae"."artifact_deletion_requests"("artifact_version_id"); +CREATE INDEX "artifact_deletion_requests_scope_state_idx" ON "iae"."artifact_deletion_requests"("organization_id", "workspace_id", "project_id", "state"); + +CREATE TABLE "iae"."artifact_export_manifests" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "entries" JSONB NOT NULL, + "approval_state" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "canonical_hash" CHAR(64) NOT NULL, + CONSTRAINT "artifact_export_manifests_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "artifact_export_manifests_scope_idx" ON "iae"."artifact_export_manifests"("organization_id", "workspace_id", "project_id"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 0cced72a..da4a5481 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -127,3 +127,41 @@ model EvidenceGrantRecord { @@map("evidence_grants") @@schema("iae") } + +/// IAE-016, IAE-021: deletion authorization is durable and separate from byte erasure. +model ArtifactDeletionRequestRecord { + id String @id @db.Uuid + artifactVersionId String @map("artifact_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + requestedBy String @map("requested_by") @db.Uuid + requestedAt DateTime @map("requested_at") @db.Timestamptz(6) + state String @db.VarChar(16) + blockers Json + authorizedAt DateTime? @map("authorized_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([artifactVersionId], map: "artifact_deletion_requests_artifact_idx") + @@index([organizationId, workspaceId, projectId, state], map: "artifact_deletion_requests_scope_state_idx") + @@map("artifact_deletion_requests") + @@schema("iae") +} + +/// IAE-018: export manifests contain only verification metadata and references. +model ArtifactExportManifestRecord { + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + entries Json + approvalState String @map("approval_state") @db.VarChar(16) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + canonicalHash String @map("canonical_hash") @db.Char(64) + + @@index([organizationId, workspaceId, projectId], map: "artifact_export_manifests_scope_idx") + @@map("artifact_export_manifests") + @@schema("iae") +} From 538cff4332c786efdb53d1bfb4d41e081faf9074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:25:20 +0700 Subject: [PATCH 14/74] fix(iae): make Prisma placement registration idempotent --- .../iae/adapter/prisma-artifact-repository.adapter.ts | 8 ++++++++ .../test/features/iae/prisma-artifact-repository.test.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index f4dd3950..e87e9cd9 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -245,6 +245,14 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { if (version === null) throw new Error('IAE_VERSION_NOT_FOUND'); if (!tenantScopeContainsV1(context.tenantScope, placement.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.contentPlacement.findUnique({ + where: { id: placement.placementId }, + }); + if (existing !== null) { + const persisted = rowToPlacement(existing, rowToVersion(version)); + if (JSON.stringify(persisted) === JSON.stringify(placement)) return; + throw new Error('IAE_IMMUTABLE_PLACEMENT'); + } await this.client.contentPlacement.create({ data: { ...databaseScope(placement.tenantScope), diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index b50896b7..95fddcc9 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -150,6 +150,7 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl const repository = new PrismaArtifactRepositoryAdapter(client([], placements, evidence)); await repository.saveVersion(context('version'), artifact.value); await repository.savePlacement(context('placement'), placement.value); + await repository.savePlacement(context('placement-repeat'), placement.value); await repository.saveEvidence(context('evidence'), evidenceRef.value); assert.equal((await repository.listPlacements(context('list-placement'), versionId)).length, 1); assert.equal((await repository.listEvidence(context('list-evidence'), versionId)).length, 1); From 2ce24da7b4517251a91b376d947999cec3af8c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:27:26 +0700 Subject: [PATCH 15/74] feat(dsm): add Prisma dataset version adapter --- ...isma-dataset-version-repository.adapter.ts | 170 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 11 +- .../prisma-dataset-version-repository.test.ts | 82 +++++++++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-version-repository.test.ts diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts new file mode 100644 index 00000000..368ebbeb --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts @@ -0,0 +1,170 @@ +import { + createDatasetVersionManifestV1, + type DatasetVersionManifestV1, +} from '@databreeze/domain/dataset-governance/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetVersionRepositoryPortV1, + DatasetVersionTransactionPortV1, +} from '../application/dataset-version-repository.port.js'; + +export interface DatasetVersionDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly inputArtifactVersionIds: unknown; + readonly schemaVersionId: string; + readonly mappingVersionId: string; + readonly ruleSetVersionId: string; + readonly engineBuild: string; + readonly contentFingerprint: string; + readonly rowCount: bigint | number; + readonly qualityState: string; + readonly lineageManifestHash: string; + readonly createdAt: Date; +} + +export interface DatasetVersionDatabaseCreateDataV1 + extends Omit { + readonly rowCount: bigint; + readonly createdAt: Date; +} + +export interface DatasetVersionDatabaseClientV1 { + readonly datasetVersionRecord: { + create(input: { + readonly data: DatasetVersionDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetVersionDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: DatasetVersionDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: DatasetVersionDatabaseRowV1): DatasetVersionManifestV1 { + const parsed = createDatasetVersionManifestV1({ + datasetId: row.datasetId, + versionId: row.id, + tenantScope: rowScope(row), + inputArtifactVersionIds: row.inputArtifactVersionIds, + schemaVersionId: row.schemaVersionId, + mappingVersionId: row.mappingVersionId, + ruleSetVersionId: row.ruleSetVersionId, + engineBuild: row.engineBuild, + contentFingerprint: row.contentFingerprint, + rowCount: typeof row.rowCount === 'bigint' ? Number(row.rowCount) : row.rowCount, + qualityState: row.qualityState, + lineageManifestHash: row.lineageManifestHash, + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_DATASET_VERSION_INVALID'); + return parsed.value; +} + +function domainToCreate(version: DatasetVersionManifestV1): DatasetVersionDatabaseCreateDataV1 { + return { + ...databaseScope(version.tenantScope), + id: version.versionId, + datasetId: version.datasetId, + inputArtifactVersionIds: version.inputArtifactVersionIds, + schemaVersionId: version.schemaVersionId, + mappingVersionId: version.mappingVersionId, + ruleSetVersionId: version.ruleSetVersionId, + engineBuild: version.engineBuild, + contentFingerprint: version.contentFingerprint, + rowCount: BigInt(version.rowCount), + qualityState: version.qualityState, + lineageManifestHash: version.lineageManifestHash, + createdAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, row: DatasetVersionDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetVersionTransactionAdapter implements DatasetVersionTransactionPortV1 { + public constructor(private readonly client: DatasetVersionDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetVersionRecord.findUnique({ + where: { id: version.versionId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(version)) + throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); + return; + } + await this.client.datasetVersionRecord.create({ data: domainToCreate(version) }); + } + + public async find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise { + const row = await this.client.datasetVersionRecord.findUnique({ where: { id: versionId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } +} + +export class PrismaDatasetVersionRepositoryAdapter implements DatasetVersionRepositoryPortV1 { + public constructor(private readonly client: DatasetVersionDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetVersionTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetVersionTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise { + return new PrismaDatasetVersionTransactionAdapter(this.client).save(context, version); + } + + public find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise { + return new PrismaDatasetVersionTransactionAdapter(this.client).find(context, versionId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 2a15d059..903e4325 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -22,6 +22,10 @@ import { } from './adapter/prisma-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; import { InMemoryDatasetVersionRepositoryAdapter } from './adapter/in-memory-dataset-version-repository.adapter.js'; +import { + PrismaDatasetVersionRepositoryAdapter, + type DatasetVersionDatabaseClientV1, +} from './adapter/prisma-dataset-version-repository.adapter.js'; import { PrismaRuleSetRepositoryAdapter, type RuleSetDatabaseClientV1, @@ -66,6 +70,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly referenceEntityDatabase?: ReferenceEntityDatabaseClientV1; readonly datasetVersionRepository?: DatasetVersionRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetVersionDatabase?: DatasetVersionDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -117,7 +123,10 @@ export class DsmModule { { provide: DATASET_VERSION_REPOSITORY_PORT, useValue: - options.datasetVersionRepository ?? new InMemoryDatasetVersionRepositoryAdapter(), + options.datasetVersionRepository ?? + (options.datasetVersionDatabase === undefined + ? new InMemoryDatasetVersionRepositoryAdapter() + : new PrismaDatasetVersionRepositoryAdapter(options.datasetVersionDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts new file mode 100644 index 00000000..ecf4cbdf --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts @@ -0,0 +1,82 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createDatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaDatasetVersionRepositoryAdapter, + type DatasetVersionDatabaseClientV1, + type DatasetVersionDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-version-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000811'); +const workspaceId = id('00000000-0000-4000-8000-000000000812'); +const versionId = id('00000000-0000-4000-8000-000000000813'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000814', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000815', + idempotencyKey: 'prisma-dataset-version', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: DatasetVersionDatabaseRowV1[]): DatasetVersionDatabaseClientV1 { + return { + datasetVersionRecord: { + create({ data }) { + const persisted = { ...data } as DatasetVersionDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-002, DSM-003, IAM-009] Prisma dataset version adapter is immutable and tenant scoped', async () => { + const rows: DatasetVersionDatabaseRowV1[] = []; + const repository = new PrismaDatasetVersionRepositoryAdapter(client(rows)); + const tenantContext = context(); + const created = createDatasetVersionManifestV1({ + datasetId: '00000000-0000-4000-8000-000000000816', + versionId, + tenantScope: tenantContext.tenantScope, + inputArtifactVersionIds: ['00000000-0000-4000-8000-000000000817'], + schemaVersionId: '00000000-0000-4000-8000-000000000818', + mappingVersionId: '00000000-0000-4000-8000-000000000819', + ruleSetVersionId: '00000000-0000-4000-8000-000000000820', + engineBuild: 'engine@1', + contentFingerprint: 'a'.repeat(64), + rowCount: 5, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, versionId), created.value); + assert.equal(rows.length, 1); +}); From 9808e8f89b2c3c75f13a278ebebf145aaed6ca50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:30:27 +0700 Subject: [PATCH 16/74] feat(iae): add Prisma retention repository --- ...a-artifact-retention-repository.adapter.ts | 201 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 11 +- ...isma-artifact-retention-repository.test.ts | 82 +++++++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-retention-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts new file mode 100644 index 00000000..7948a67b --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts @@ -0,0 +1,201 @@ +import { + createArtifactDeletionRequestV1, + type ArtifactDeletionRequestV1, +} from '@databreeze/domain/artifact-retention/v1'; +import { + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactRetentionRepositoryPortV1, + ArtifactRetentionTransactionPortV1, +} from '../application/artifact-retention-repository.port.js'; + +export interface ArtifactRetentionDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly requestedBy: string; + readonly requestedAt: Date; + readonly state: string; + readonly blockers: unknown; + readonly authorizedAt: Date | null; + readonly revision: number; +} + +export interface ArtifactRetentionDatabaseCreateDataV1 + extends Omit { + readonly authorizedAt: Date | null; +} + +export interface ArtifactRetentionDatabaseClientV1 { + readonly artifactDeletionRequestRecord: { + create(input: { + readonly data: ArtifactRetentionDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { + readonly state: string; + readonly blockers: unknown; + readonly authorizedAt: Date | null; + readonly revision: number; + }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactRetentionDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: ArtifactRetentionDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactRetentionDatabaseRowV1): ArtifactDeletionRequestV1 { + const created = createArtifactDeletionRequestV1({ + requestId: row.id, + artifactVersionId: row.artifactVersionId, + tenantScope: rowScope(row), + requestedBy: row.requestedBy, + requestedAt: row.requestedAt.toISOString(), + }); + if (!created.accepted) throw new Error('IAE_PERSISTED_DELETION_REQUEST_INVALID'); + if (!['REQUESTED', 'BLOCKED', 'AUTHORIZED', 'COMPLETED', 'CANCELLED'].includes(row.state)) + throw new Error('IAE_PERSISTED_DELETION_STATE_INVALID'); + if (!Array.isArray(row.blockers) || !row.blockers.every((value) => typeof value === 'string')) + throw new Error('IAE_PERSISTED_DELETION_BLOCKERS_INVALID'); + if (!Number.isSafeInteger(row.revision) || row.revision < 1) + throw new Error('IAE_PERSISTED_REVISION_INVALID'); + const authorizedAt = row.authorizedAt?.toISOString(); + const parsedAuthorizedAt = authorizedAt ? parseStrictUtcTimestampV1(authorizedAt) : undefined; + if (parsedAuthorizedAt && !parsedAuthorizedAt.accepted) + throw new Error('IAE_PERSISTED_TIMESTAMP_INVALID'); + return Object.freeze({ + ...created.value, + state: row.state as ArtifactDeletionRequestV1['state'], + blockers: Object.freeze([...row.blockers]), + ...(parsedAuthorizedAt?.accepted ? { authorizedAt: parsedAuthorizedAt.value } : {}), + revision: row.revision, + }); +} + +function domainToCreate(request: ArtifactDeletionRequestV1): ArtifactRetentionDatabaseCreateDataV1 { + return { + ...databaseScope(request.tenantScope), + id: request.requestId, + artifactVersionId: request.artifactVersionId, + requestedBy: request.requestedBy, + requestedAt: new Date(request.requestedAt), + state: request.state, + blockers: request.blockers, + authorizedAt: request.authorizedAt ? new Date(request.authorizedAt) : null, + revision: request.revision, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactRetentionDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactRetentionTransactionAdapter implements ArtifactRetentionTransactionPortV1 { + public constructor(private readonly client: ArtifactRetentionDatabaseClientV1) {} + + public async save( + context: IamTenantContextV1, + request: ArtifactDeletionRequestV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactDeletionRequestRecord.findUnique({ + where: { id: request.requestId }, + }); + if (existing === null) { + await this.client.artifactDeletionRequestRecord.create({ data: domainToCreate(request) }); + return; + } + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(request)) return; + if (request.revision !== current.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + current.artifactVersionId !== request.artifactVersionId || + current.requestedBy !== request.requestedBy || + current.requestedAt !== request.requestedAt + ) + throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); + await this.client.artifactDeletionRequestRecord.update({ + where: { id: request.requestId }, + data: { + state: request.state, + blockers: request.blockers, + authorizedAt: request.authorizedAt ? new Date(request.authorizedAt) : null, + revision: request.revision, + }, + }); + } + + public async find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise { + const row = await this.client.artifactDeletionRequestRecord.findUnique({ + where: { id: requestId }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } +} + +export class PrismaArtifactRetentionRepositoryAdapter implements ArtifactRetentionRepositoryPortV1 { + public constructor(private readonly client: ArtifactRetentionDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactRetentionTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactRetentionTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, request: ArtifactDeletionRequestV1): Promise { + return new PrismaArtifactRetentionTransactionAdapter(this.client).save(context, request); + } + + public find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise { + return new PrismaArtifactRetentionTransactionAdapter(this.client).find(context, requestId); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 6bf2a24e..2aef6bdf 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -15,6 +15,10 @@ import { import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; +import { + PrismaArtifactRetentionRepositoryAdapter, + type ArtifactRetentionDatabaseClientV1, +} from './adapter/prisma-artifact-retention-repository.adapter.js'; import { InMemoryArtifactExportRepositoryAdapter } from './adapter/in-memory-artifact-export-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, @@ -60,6 +64,8 @@ export interface IaeModuleOptions { readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactRetentionDatabase?: ArtifactRetentionDatabaseClientV1; readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; @@ -104,7 +110,10 @@ export class IaeModule { { provide: ARTIFACT_RETENTION_REPOSITORY_PORT, useValue: - options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + options.artifactRetentionRepository ?? + (options.artifactRetentionDatabase === undefined + ? new InMemoryArtifactRetentionRepositoryAdapter() + : new PrismaArtifactRetentionRepositoryAdapter(options.artifactRetentionDatabase)), }, { provide: ARTIFACT_EXPORT_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts new file mode 100644 index 00000000..01cfae2c --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts @@ -0,0 +1,82 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaArtifactRetentionRepositoryAdapter, + type ArtifactRetentionDatabaseClientV1, + type ArtifactRetentionDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-retention-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000821'); +const workspaceId = id('00000000-0000-4000-8000-000000000822'); +const requestId = id('00000000-0000-4000-8000-000000000823'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000824', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000825', + idempotencyKey: 'prisma-retention', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: ArtifactRetentionDatabaseRowV1[]): ArtifactRetentionDatabaseClientV1 { + return { + artifactDeletionRequestRecord: { + create({ data }) { + const persisted = { ...data } as ArtifactRetentionDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (!current) throw new Error('fixture retention request not found'); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[IAE-016, IAE-021, IAM-009] Prisma retention adapter preserves immutable request identity and revisions', async () => { + const rows: ArtifactRetentionDatabaseRowV1[] = []; + const repository = new PrismaArtifactRetentionRepositoryAdapter(client(rows)); + const tenantContext = context(); + const created = createArtifactDeletionRequestV1({ + requestId, + artifactVersionId: '00000000-0000-4000-8000-000000000826', + tenantScope: tenantContext.tenantScope, + requestedBy: tenantContext.actorId, + requestedAt: '2026-01-03T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, requestId), created.value); + assert.equal(rows.length, 1); +}); From 8d1baac9a6b9ecb8260880ac3e7ce8a3e06aa269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:32:36 +0700 Subject: [PATCH 17/74] feat(iae): add Prisma export repository --- ...isma-artifact-export-repository.adapter.ts | 154 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 11 +- .../prisma-artifact-export-repository.test.ts | 68 ++++++++ 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-export-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts new file mode 100644 index 00000000..1fb55e4b --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts @@ -0,0 +1,154 @@ +import { + createArtifactExportManifestV1, + type ArtifactExportManifestV1, +} from '@databreeze/domain/artifact-export/v1'; +import { + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactExportRepositoryPortV1, + ArtifactExportTransactionPortV1, +} from '../application/artifact-export-repository.port.js'; + +export interface ArtifactExportDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly entries: unknown; + readonly approvalState: string; + readonly createdAt: Date; + readonly canonicalHash: string; +} + +export interface ArtifactExportDatabaseClientV1 { + readonly artifactExportManifestRecord: { + create(input: { + readonly data: ArtifactExportDatabaseRowV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactExportDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: ArtifactExportDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactExportDatabaseRowV1): ArtifactExportManifestV1 { + const createdAt = row.createdAt.toISOString(); + if (!parseStrictUtcTimestampV1(createdAt).accepted) + throw new Error('IAE_PERSISTED_TIMESTAMP_INVALID'); + const parsed = createArtifactExportManifestV1({ + manifestId: row.id, + tenantScope: rowScope(row), + entries: row.entries, + approvalState: row.approvalState, + createdAt, + canonicalHash: row.canonicalHash, + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_EXPORT_MANIFEST_INVALID'); + return parsed.value; +} + +function domainToCreate(manifest: ArtifactExportManifestV1): ArtifactExportDatabaseRowV1 { + return { + ...databaseScope(manifest.tenantScope), + id: manifest.manifestId, + entries: manifest.entries, + approvalState: manifest.approvalState, + createdAt: new Date(manifest.createdAt), + canonicalHash: manifest.canonicalHash, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactExportDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactExportTransactionAdapter implements ArtifactExportTransactionPortV1 { + public constructor(private readonly client: ArtifactExportDatabaseClientV1) {} + + public async save( + context: IamTenantContextV1, + manifest: ArtifactExportManifestV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactExportManifestRecord.findUnique({ + where: { id: manifest.manifestId }, + }); + if (existing !== null) { + const current = rowToDomain(existing); + if (JSON.stringify(current) !== JSON.stringify(manifest)) + throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); + return; + } + await this.client.artifactExportManifestRecord.create({ data: domainToCreate(manifest) }); + } + + public async find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise { + const row = await this.client.artifactExportManifestRecord.findUnique({ + where: { id: manifestId }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } +} + +export class PrismaArtifactExportRepositoryAdapter implements ArtifactExportRepositoryPortV1 { + public constructor(private readonly client: ArtifactExportDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactExportTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactExportTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, manifest: ArtifactExportManifestV1): Promise { + return new PrismaArtifactExportTransactionAdapter(this.client).save(context, manifest); + } + + public find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise { + return new PrismaArtifactExportTransactionAdapter(this.client).find(context, manifestId); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 2aef6bdf..5c79bbb5 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -20,6 +20,10 @@ import { type ArtifactRetentionDatabaseClientV1, } from './adapter/prisma-artifact-retention-repository.adapter.js'; import { InMemoryArtifactExportRepositoryAdapter } from './adapter/in-memory-artifact-export-repository.adapter.js'; +import { + PrismaArtifactExportRepositoryAdapter, + type ArtifactExportDatabaseClientV1, +} from './adapter/prisma-artifact-export-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -67,6 +71,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactRetentionDatabase?: ArtifactRetentionDatabaseClientV1; readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactExportDatabase?: ArtifactExportDatabaseClientV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -118,7 +124,10 @@ export class IaeModule { { provide: ARTIFACT_EXPORT_REPOSITORY_PORT, useValue: - options.artifactExportRepository ?? new InMemoryArtifactExportRepositoryAdapter(), + options.artifactExportRepository ?? + (options.artifactExportDatabase === undefined + ? new InMemoryArtifactExportRepositoryAdapter() + : new PrismaArtifactExportRepositoryAdapter(options.artifactExportDatabase)), }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/prisma-artifact-export-repository.test.ts b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts new file mode 100644 index 00000000..a3d71b97 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createArtifactExportManifestV1 } from '@databreeze/domain/artifact-export/v1'; +import { parseTenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +import { PrismaArtifactExportRepositoryAdapter } from '../../../src/features/iae/adapter/prisma-artifact-export-repository.adapter.js'; + +const organizationId = '11111111-1111-4111-8111-111111111111'; +const workspaceId = '22222222-2222-4222-8222-222222222222'; +const scopeResult = parseTenantScopeV1({ + scopeType: 'workspace', + organizationId, + workspaceId, +}); +if (!scopeResult.accepted) throw new Error('fixture scope invalid'); +const scope = scopeResult.value; +const contextResult = createIamTenantContextV1({ + actorId: '55555555-5555-4555-8555-555555555555', + tenantScope: scope, + authorizationEpoch: 1, + correlationId: '66666666-6666-4666-8666-666666666666', + idempotencyKey: 'prisma-export', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const manifest = createArtifactExportManifestV1({ + manifestId: '33333333-3333-4333-8333-333333333333', + tenantScope: scope, + entries: [ + { + versionId: '44444444-4444-4444-8444-444444444444', + contentSha256: 'a'.repeat(64), + byteSize: 32, + evidenceIds: [], + processorVersions: ['spreadsheet-auditor@1'], + }, + ], + approvalState: 'PENDING', + createdAt: '2026-08-02T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), +}); +if (!manifest.accepted) throw new Error('fixture manifest invalid'); + +test('IAE-018 Prisma export adapter preserves immutable manifests and scopes reads', async () => { + const rows = new Map(); + const client = { + artifactExportManifestRecord: { + async create({ data }: any) { + const row = { ...data }; + rows.set(row.id, row); + return row; + }, + async findUnique({ where }: any) { + return rows.get(where.id) ?? null; + }, + }, + async $transaction(work: any) { + return work(this); + }, + } as any; + const repository = new PrismaArtifactExportRepositoryAdapter(client); + await repository.save(context, manifest.value); + await repository.save(context, manifest.value); + assert.deepEqual(await repository.find(context, manifest.value.manifestId), manifest.value); + assert.equal(rows.size, 1); +}); From 72131d1967402e1b7aa79a747f8aad837bda0282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:34:25 +0700 Subject: [PATCH 18/74] feat(iae): add Prisma lineage repository --- ...sma-artifact-lineage-repository.adapter.ts | 176 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 11 +- ...prisma-artifact-lineage-repository.test.ts | 83 +++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts new file mode 100644 index 00000000..0bd74f25 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts @@ -0,0 +1,176 @@ +import { + createArtifactLineageV1, + type ArtifactLineageV1, +} from '@databreeze/domain/artifact-governance/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactLineageRepositoryPortV1, + ArtifactLineageTransactionPortV1, +} from '../application/artifact-lineage-repository.port.js'; + +export interface ArtifactLineageDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly derivedArtifactVersionId: string; + readonly sourceVersionIds: unknown; + readonly processorVersion: string; + readonly recipeVersion: string | null; + readonly coordinateLineage: unknown; +} + +export interface ArtifactLineageDatabaseClientV1 { + readonly artifactLineageRecord: { + create(input: { + readonly data: ArtifactLineageDatabaseRowV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findFirst(input: { + readonly where: { readonly derivedArtifactVersionId: string }; + }): Promise; + findMany(input: { + readonly where: { readonly sourceVersionIds: { readonly array_contains: string } }; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactLineageDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: ArtifactLineageDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactLineageDatabaseRowV1): ArtifactLineageV1 { + const parsed = createArtifactLineageV1({ + lineageId: row.id, + derivedArtifactVersionId: row.derivedArtifactVersionId, + tenantScope: rowScope(row), + sourceArtifactVersionIds: row.sourceVersionIds, + processorVersion: row.processorVersion, + ...(row.recipeVersion === null ? {} : { recipeVersion: row.recipeVersion }), + coordinateLineage: row.coordinateLineage, + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_LINEAGE_INVALID'); + return parsed.value; +} + +function domainToCreate(lineage: ArtifactLineageV1): ArtifactLineageDatabaseRowV1 { + return { + ...databaseScope(lineage.tenantScope), + id: lineage.lineageId, + derivedArtifactVersionId: lineage.derivedArtifactVersionId, + sourceVersionIds: lineage.sourceArtifactVersionIds, + processorVersion: lineage.processorVersion, + recipeVersion: lineage.recipeVersion ?? null, + coordinateLineage: lineage.coordinateLineage, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactLineageDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactLineageTransactionAdapter implements ArtifactLineageTransactionPortV1 { + public constructor(private readonly client: ArtifactLineageDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, lineage: ArtifactLineageV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, lineage.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactLineageRecord.findUnique({ + where: { id: lineage.lineageId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(lineage)) + throw new Error('IAE_IMMUTABLE_LINEAGE'); + return; + } + await this.client.artifactLineageRecord.create({ data: domainToCreate(lineage) }); + } + + public async findByDerived( + context: IamTenantContextV1, + derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], + ): Promise { + const row = await this.client.artifactLineageRecord.findFirst({ + where: { derivedArtifactVersionId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } + + public async listBySource( + context: IamTenantContextV1, + sourceArtifactVersionId: ArtifactLineageV1['sourceArtifactVersionIds'][number], + ): Promise { + const rows = await this.client.artifactLineageRecord.findMany({ + where: { sourceVersionIds: { array_contains: sourceArtifactVersionId } }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaArtifactLineageRepositoryAdapter implements ArtifactLineageRepositoryPortV1 { + public constructor(private readonly client: ArtifactLineageDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactLineageTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactLineageTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, lineage: ArtifactLineageV1): Promise { + return new PrismaArtifactLineageTransactionAdapter(this.client).save(context, lineage); + } + + public findByDerived( + context: IamTenantContextV1, + derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], + ): Promise { + return new PrismaArtifactLineageTransactionAdapter(this.client).findByDerived( + context, + derivedArtifactVersionId, + ); + } + + public listBySource( + context: IamTenantContextV1, + sourceArtifactVersionId: ArtifactLineageV1['sourceArtifactVersionIds'][number], + ): Promise { + return new PrismaArtifactLineageTransactionAdapter(this.client).listBySource( + context, + sourceArtifactVersionId, + ); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 5c79bbb5..2d7f48cb 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -14,6 +14,10 @@ import { } from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { + PrismaArtifactLineageRepositoryAdapter, + type ArtifactLineageDatabaseClientV1, +} from './adapter/prisma-artifact-lineage-repository.adapter.js'; import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; import { PrismaArtifactRetentionRepositoryAdapter, @@ -67,6 +71,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactLineageDatabase?: ArtifactLineageDatabaseClientV1; readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactRetentionDatabase?: ArtifactRetentionDatabaseClientV1; @@ -111,7 +117,10 @@ export class IaeModule { { provide: ARTIFACT_LINEAGE_REPOSITORY_PORT, useValue: - options.artifactLineageRepository ?? new InMemoryArtifactLineageRepositoryAdapter(), + options.artifactLineageRepository ?? + (options.artifactLineageDatabase === undefined + ? new InMemoryArtifactLineageRepositoryAdapter() + : new PrismaArtifactLineageRepositoryAdapter(options.artifactLineageDatabase)), }, { provide: ARTIFACT_RETENTION_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts new file mode 100644 index 00000000..3605a398 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts @@ -0,0 +1,83 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactLineageV1 } from '@databreeze/domain/artifact-governance/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaArtifactLineageRepositoryAdapter, + type ArtifactLineageDatabaseClientV1, + type ArtifactLineageDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-lineage', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const lineageResult = createArtifactLineageV1({ + lineageId: '55555555-5555-4555-8555-555555555555', + derivedArtifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + sourceArtifactVersionIds: ['77777777-7777-4777-8777-777777777777'], + processorVersion: 'normalizer@1', + coordinateLineage: [], +}); +if (!lineageResult.accepted) throw new Error('fixture lineage invalid'); +const lineage = lineageResult.value; + +function client(rows: ArtifactLineageDatabaseRowV1[]): ArtifactLineageDatabaseClientV1 { + return { + artifactLineageRecord: { + create({ data }) { + rows.push({ ...data }); + return Promise.resolve({ ...data }); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findFirst({ where }) { + return Promise.resolve( + rows.find((row) => row.derivedArtifactVersionId === where.derivedArtifactVersionId) ?? + null, + ); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + Array.isArray(row.sourceVersionIds) && + row.sourceVersionIds.includes(where.sourceVersionIds.array_contains), + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-007 Prisma lineage adapter preserves immutable lineage and source lookup', async () => { + const rows: ArtifactLineageDatabaseRowV1[] = []; + const repository = new PrismaArtifactLineageRepositoryAdapter(client(rows)); + await repository.save(context, lineage); + await repository.save(context, lineage); + assert.deepEqual( + await repository.findByDerived(context, lineage.derivedArtifactVersionId), + lineage, + ); + const sourceVersionId = lineage.sourceArtifactVersionIds[0]; + if (!sourceVersionId) throw new Error('fixture source id missing'); + assert.deepEqual(await repository.listBySource(context, sourceVersionId), [lineage]); + assert.equal(rows.length, 1); +}); From 9bbc79ef5bde4f926c0d13c81ca502b32163b07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:36:10 +0700 Subject: [PATCH 19/74] fix(iae): persist validated inbox transitions --- ...isma-artifact-intake-repository.adapter.ts | 25 +++++++++- .../prisma-artifact-intake-repository.test.ts | 49 +++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts index 817a91ee..4f201512 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts @@ -1,5 +1,6 @@ import { createInboxItemV1, + transitionInboxItemV1, type InboxItemStateV1, type InboxItemV1, } from '@databreeze/domain/artifact-intake/v1'; @@ -56,6 +57,10 @@ export interface ArtifactIntakeDatabaseDelegateV1 { readonly where: Readonly>; readonly orderBy: { readonly createdAt: 'desc' }; }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly state: InboxItemStateV1; readonly revision: number }; + }): Promise; } export interface ArtifactIntakeDatabaseClientV1 { @@ -158,9 +163,27 @@ class PrismaArtifactIntakeTransactionAdapter implements ArtifactIntakeTransactio } const existing = await this.client.inboxItem.findUnique({ where: { id: item.inboxItemId } }); if (existing !== null) { - if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(item)) { + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(item)) return; + if (context.expectedRevision !== current.revision) { + throw new Error('IAE_REVISION_CONFLICT'); + } + if ( + current.artifactVersionId !== item.artifactVersionId || + current.idempotencyKey !== item.idempotencyKey || + JSON.stringify(current.tenantScope) !== JSON.stringify(item.tenantScope) || + item.revision !== current.revision + 1 + ) { throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); } + const transition = transitionInboxItemV1(current, item.state); + if (!transition.accepted || JSON.stringify(transition.value) !== JSON.stringify(item)) { + throw new Error('IAE_INVALID_INBOX_TRANSITION'); + } + await this.client.inboxItem.update({ + where: { id: item.inboxItemId }, + data: { state: item.state, revision: item.revision }, + }); return; } await this.client.inboxItem.create({ data: domainToCreate(item) }); diff --git a/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts b/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts index b34735c8..d16113f5 100644 --- a/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts @@ -78,6 +78,13 @@ function client(rows: ArtifactIntakeDatabaseRowV1[]): ArtifactIntakeDatabaseClie .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()), ); }, + update(input) { + const current = rows.find((candidate) => candidate.id === input.where.id); + if (!current) throw new Error('fixture inbox item not found'); + const next = { ...current, ...input.data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, }, async $transaction(work) { return work(this); @@ -135,10 +142,44 @@ void test('[IAE-001] Prisma adapter uses immutable idempotent writes', async () await repository.save(context(workspaceId, 'save-replay'), item); assert.equal(rows.length, 1); await assert.rejects( - repository.save(context(workspaceId, 'save-conflict'), { - ...item, - artifactVersionId: identifier('00000000-0000-4000-8000-000000000009'), - }), + repository.save( + { ...context(workspaceId, 'save-conflict'), expectedRevision: 1 }, + { + ...item, + artifactVersionId: identifier('00000000-0000-4000-8000-000000000009'), + }, + ), /IAE_IMMUTABLE_INBOX_ITEM/u, ); }); + +void test('[IAE-013] Prisma adapter persists only validated state transitions with revisions', async () => { + const rows: ArtifactIntakeDatabaseRowV1[] = []; + const repository = new PrismaArtifactIntakeRepositoryAdapter(client(rows)); + const item: InboxItemV1 = { + schemaVersion: 1 as const, + inboxItemId: itemId, + tenantScope: { scopeType: 'workspace' as const, organizationId, workspaceId }, + idempotencyKey: 'transition', + artifactVersionId, + state: 'NEW' as const, + createdAt: timestamp('2026-01-01T00:00:00.000Z'), + revision: 1, + }; + await repository.save(context(workspaceId, 'transition-create'), item); + await repository.save( + { ...context(workspaceId, 'transition-update'), expectedRevision: 1 }, + { ...item, state: 'ROUTED', revision: 2 }, + ); + assert.equal( + (await repository.find(context(workspaceId, 'transition-read'), itemId))?.state, + 'ROUTED', + ); + await assert.rejects( + repository.save( + { ...context(workspaceId, 'transition-stale'), expectedRevision: 1 }, + { ...item, state: 'PROCESSING', revision: 2 }, + ), + /IAE_REVISION_CONFLICT/u, + ); +}); From 1ca8cc2494b0b11198a4684698162c94f8bb774a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:38:53 +0700 Subject: [PATCH 20/74] feat(iae): add resumable upload domain --- packages/domain/package.json | 4 + packages/domain/src/artifact-upload/v1.ts | 265 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/artifact-upload-v1.test.mjs | 62 ++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 337 insertions(+) create mode 100644 packages/domain/src/artifact-upload/v1.ts create mode 100644 packages/domain/test/artifact-upload-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 12b5fb7b..ad6b3706 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -80,6 +80,10 @@ "types": "./src/artifact-export/v1.ts", "import": "./dist/artifact-export/v1.js" }, + "./artifact-upload/v1": { + "types": "./src/artifact-upload/v1.ts", + "import": "./dist/artifact-upload/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/artifact-upload/v1.ts b/packages/domain/src/artifact-upload/v1.ts new file mode 100644 index 00000000..9f719329 --- /dev/null +++ b/packages/domain/src/artifact-upload/v1.ts @@ -0,0 +1,265 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAE-014: resumable multipart upload state is bounded, revisioned, and content-addressed. */ +export const ARTIFACT_UPLOAD_SCHEMA_VERSION_V1 = 1 as const; +export type ArtifactUploadStateV1 = 'OPEN' | 'COMPLETED' | 'ABORTED' | 'EXPIRED'; + +export interface ArtifactUploadPartV1 { + readonly partNumber: number; + readonly contentSha256: string; + readonly byteSize: number; + readonly uploadedAt: StrictUtcTimestampV1; +} + +export interface ArtifactUploadSessionV1 { + readonly schemaVersion: typeof ARTIFACT_UPLOAD_SCHEMA_VERSION_V1; + readonly sessionId: StableIdentifierV1; + readonly artifactId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly expectedSha256: string; + readonly expectedByteSize: number; + readonly mediaType: string; + readonly partSize: number; + readonly totalParts: number; + readonly parts: readonly ArtifactUploadPartV1[]; + readonly state: ArtifactUploadStateV1; + readonly createdAt: StrictUtcTimestampV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ArtifactUploadErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_SIZE' + | 'INVALID_MEDIA_TYPE' + | 'INVALID_PART' + | 'INVALID_STATE' + | 'REVISION_CONFLICT' + | 'MISSING_PARTS' + | 'SIZE_MISMATCH' + | 'DIGEST_MISMATCH' + | 'EXPIRED'; + +export type ArtifactUploadResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactUploadErrorCodeV1 }; + +function accepted(value: TValue): ArtifactUploadResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactUploadErrorCodeV1): ArtifactUploadResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function mediaType(input: unknown): string | undefined { + return typeof input === 'string' && + /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu.test(input) + ? input.toLowerCase() + : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 ? input : undefined; +} + +function revision(input: unknown): number | undefined { + return positiveInteger(input); +} + +function validPart(part: unknown, totalParts: number): part is ArtifactUploadPartV1 { + if (typeof part !== 'object' || part === null || Array.isArray(part)) return false; + const record = part as Record; + return ( + typeof record['partNumber'] === 'number' && + Number.isSafeInteger(record['partNumber']) && + record['partNumber'] >= 1 && + record['partNumber'] <= totalParts && + hash(record['contentSha256']) !== undefined && + typeof record['byteSize'] === 'number' && + Number.isSafeInteger(record['byteSize']) && + record['byteSize'] >= 0 && + timestamp(record['uploadedAt']) !== undefined + ); +} + +export function createArtifactUploadSessionV1(input: { + readonly sessionId: unknown; + readonly artifactId: unknown; + readonly tenantScope: unknown; + readonly expectedSha256: unknown; + readonly expectedByteSize: unknown; + readonly mediaType: unknown; + readonly partSize: unknown; + readonly createdAt: unknown; + readonly expiresAt: unknown; +}): ArtifactUploadResultV1 { + const sessionId = identifier(input.sessionId); + const artifactId = identifier(input.artifactId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const expectedSha256 = hash(input.expectedSha256); + const expectedByteSize = positiveInteger(input.expectedByteSize ?? 0); + const partSize = positiveInteger(input.partSize); + const mediaTypeValue = mediaType(input.mediaType); + const createdAt = timestamp(input.createdAt); + const expiresAt = timestamp(input.expiresAt); + if (!sessionId || !artifactId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!expectedSha256) return rejected('INVALID_HASH'); + if ( + typeof input.expectedByteSize !== 'number' || + !Number.isSafeInteger(input.expectedByteSize) || + input.expectedByteSize < 0 + ) + return rejected('INVALID_SIZE'); + if (!partSize || partSize > 1024 * 1024 * 1024) return rejected('INVALID_SIZE'); + if (!mediaTypeValue) return rejected('INVALID_MEDIA_TYPE'); + if (!createdAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); + const totalParts = Math.max(1, Math.ceil(input.expectedByteSize / partSize)); + if (totalParts > 10_000) return rejected('INVALID_SIZE'); + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, + sessionId, + artifactId, + tenantScope: tenantScope.value, + expectedSha256, + expectedByteSize: input.expectedByteSize, + mediaType: mediaTypeValue, + partSize, + totalParts, + parts: Object.freeze([]), + state: 'OPEN' as const, + createdAt, + expiresAt, + revision: 1, + }), + ); +} + +export function recordArtifactUploadPartV1( + session: ArtifactUploadSessionV1, + input: { + readonly partNumber: unknown; + readonly contentSha256: unknown; + readonly byteSize: unknown; + readonly uploadedAt: unknown; + readonly expectedRevision: unknown; + }, +): ArtifactUploadResultV1 { + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (Date.parse(input.uploadedAt as string) > Date.parse(session.expiresAt)) + return rejected('EXPIRED'); + if (input.expectedRevision !== session.revision) return rejected('REVISION_CONFLICT'); + const partNumber = input.partNumber; + const contentSha256 = hash(input.contentSha256); + const byteSize = input.byteSize; + const uploadedAt = timestamp(input.uploadedAt); + if ( + typeof partNumber !== 'number' || + !Number.isSafeInteger(partNumber) || + partNumber < 1 || + partNumber > session.totalParts || + !contentSha256 || + typeof byteSize !== 'number' || + !Number.isSafeInteger(byteSize) || + byteSize < 0 || + !uploadedAt || + byteSize > session.partSize || + (partNumber < session.totalParts && byteSize !== session.partSize) + ) + return rejected('INVALID_PART'); + const existing = session.parts.find((part) => part.partNumber === partNumber); + if (existing) { + return existing.contentSha256 === contentSha256 && existing.byteSize === byteSize + ? accepted(session) + : rejected('DIGEST_MISMATCH'); + } + const part = Object.freeze({ partNumber, contentSha256, byteSize, uploadedAt }); + return accepted( + Object.freeze({ + ...session, + parts: Object.freeze( + [...session.parts, part].sort((left, right) => left.partNumber - right.partNumber), + ), + revision: session.revision + 1, + }), + ); +} + +export function completeArtifactUploadSessionV1( + session: ArtifactUploadSessionV1, + input: { readonly assembledSha256: unknown; readonly expectedRevision: unknown }, +): ArtifactUploadResultV1 { + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (input.expectedRevision !== session.revision) return rejected('REVISION_CONFLICT'); + const assembledSha256 = hash(input.assembledSha256); + if (!assembledSha256) return rejected('INVALID_HASH'); + if (session.parts.length !== session.totalParts) return rejected('MISSING_PARTS'); + if (session.parts.some((part, index) => part.partNumber !== index + 1)) + return rejected('MISSING_PARTS'); + if (session.parts.reduce((total, part) => total + part.byteSize, 0) !== session.expectedByteSize) + return rejected('SIZE_MISMATCH'); + if (assembledSha256 !== session.expectedSha256) return rejected('DIGEST_MISMATCH'); + return accepted( + Object.freeze({ ...session, state: 'COMPLETED' as const, revision: session.revision + 1 }), + ); +} + +export function abortArtifactUploadSessionV1( + session: ArtifactUploadSessionV1, + expectedRevision: unknown, +): ArtifactUploadResultV1 { + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (expectedRevision !== session.revision) return rejected('REVISION_CONFLICT'); + return accepted( + Object.freeze({ ...session, state: 'ABORTED' as const, revision: session.revision + 1 }), + ); +} + +export function expireArtifactUploadSessionV1( + session: ArtifactUploadSessionV1, + now: unknown, +): ArtifactUploadResultV1 { + const timestampValue = timestamp(now); + if (!timestampValue) return rejected('INVALID_TIMESTAMP'); + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (Date.parse(timestampValue) < Date.parse(session.expiresAt)) return rejected('EXPIRED'); + return accepted( + Object.freeze({ ...session, state: 'EXPIRED' as const, revision: session.revision + 1 }), + ); +} + +export function isArtifactUploadPartV1( + part: unknown, + totalParts: number, +): part is ArtifactUploadPartV1 { + return validPart(part, totalParts); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 2034d52e..8826736f 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -5,6 +5,7 @@ export * from './artifact-intake/v1.js'; export * from './artifact-governance/v1.js'; export * from './artifact-retention/v1.js'; export * from './artifact-export/v1.js'; +export * from './artifact-upload/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; diff --git a/packages/domain/test/artifact-upload-v1.test.mjs b/packages/domain/test/artifact-upload-v1.test.mjs new file mode 100644 index 00000000..d40778a0 --- /dev/null +++ b/packages/domain/test/artifact-upload-v1.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + completeArtifactUploadSessionV1, + createArtifactUploadSessionV1, + recordArtifactUploadPartV1, +} from '../dist/artifact-upload/v1.js'; + +const base = { + sessionId: '11111111-1111-4111-8111-111111111111', + artifactId: '22222222-2222-4222-8222-222222222222', + tenantScope: { + scopeType: 'workspace', + organizationId: '33333333-3333-4333-8333-333333333333', + workspaceId: '44444444-4444-4444-8444-444444444444', + }, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 8, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', +}; + +void test('[IAE-014] upload sessions require every bounded part before completion', () => { + const created = createArtifactUploadSessionV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const first = recordArtifactUploadPartV1(created.value, { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:10:00.000Z', + expectedRevision: 1, + }); + assert.equal(first.accepted, true); + if (!first.accepted) return; + assert.deepEqual( + completeArtifactUploadSessionV1(first.value, { + assembledSha256: base.expectedSha256, + expectedRevision: 2, + }), + { accepted: false, code: 'MISSING_PARTS' }, + ); + const second = recordArtifactUploadPartV1(first.value, { + partNumber: 2, + contentSha256: 'c'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:11:00.000Z', + expectedRevision: 2, + }); + assert.equal(second.accepted, true); + if (!second.accepted) return; + assert.equal( + completeArtifactUploadSessionV1(second.value, { + assembledSha256: base.expectedSha256, + expectedRevision: 3, + }).value.state, + 'COMPLETED', + ); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index a57af3f5..f6467ab0 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -10,6 +10,7 @@ const [ artifactGovernance, artifactRetention, artifactExport, + artifactUpload, dataset, datasetGovernance, dataMode, @@ -34,6 +35,7 @@ const [ import('@databreeze/domain/artifact-governance/v1'), import('@databreeze/domain/artifact-retention/v1'), import('@databreeze/domain/artifact-export/v1'), + import('@databreeze/domain/artifact-upload/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), @@ -60,6 +62,7 @@ assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); +assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 78400cdf..aca1d84d 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -28,6 +28,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-governance/v1', './artifact-retention/v1', './artifact-export/v1', + './artifact-upload/v1', './dataset/v1', './dataset-governance/v1', './jobs/v1', @@ -66,6 +67,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); + assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); assert.equal(aggregate.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.RULE_SET_SCHEMA_VERSION_V1, 1); From 67713f2d7a9226f1d53dae5e3a91d4b184abc97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:40:11 +0700 Subject: [PATCH 21/74] feat(iae): coordinate resumable upload sessions --- ...mory-artifact-upload-repository.adapter.ts | 80 ++++++++++++++ .../artifact-upload-repository.port.ts | 20 ++++ .../application/artifact-upload.service.ts | 101 ++++++++++++++++++ .../iae/artifact-upload.service.test.ts | 53 +++++++++ 4 files changed, 254 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts create mode 100644 services/api/src/features/iae/application/artifact-upload-repository.port.ts create mode 100644 services/api/src/features/iae/application/artifact-upload.service.ts create mode 100644 services/api/test/features/iae/artifact-upload.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts new file mode 100644 index 00000000..2e2043eb --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts @@ -0,0 +1,80 @@ +import { + tenantScopeContainsV1, + type ArtifactUploadSessionV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactUploadRepositoryPortV1, + ArtifactUploadTransactionPortV1, +} from '../application/artifact-upload-repository.port.js'; + +function clone(session: ArtifactUploadSessionV1): ArtifactUploadSessionV1 { + return Object.freeze({ + ...session, + tenantScope: Object.freeze({ ...session.tenantScope }), + parts: Object.freeze(session.parts.map((part) => Object.freeze({ ...part }))), + }); +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +export class InMemoryArtifactUploadRepositoryAdapter implements ArtifactUploadRepositoryPortV1 { + private sessions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.sessions.get(session.sessionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(session)) return; + if (session.revision !== existing.revision + 1) + throw new Error('IAE_UPLOAD_REVISION_CONFLICT'); + if ( + existing.artifactId !== session.artifactId || + existing.expectedSha256 !== session.expectedSha256 || + existing.expectedByteSize !== session.expectedByteSize || + JSON.stringify(existing.tenantScope) !== JSON.stringify(session.tenantScope) + ) + throw new Error('IAE_UPLOAD_IMMUTABLE_IDENTITY'); + } + this.sessions.set(session.sessionId, clone(session)); + } + + public async find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + await Promise.resolve(); + const session = this.sessions.get(sessionId); + return session && visible(context.tenantScope, session.tenantScope) + ? clone(session) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactUploadTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.sessions); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.sessions = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/application/artifact-upload-repository.port.ts b/services/api/src/features/iae/application/artifact-upload-repository.port.ts new file mode 100644 index 00000000..f05d2961 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-upload-repository.port.ts @@ -0,0 +1,20 @@ +import type { ArtifactUploadSessionV1 } from '@databreeze/domain/artifact-upload/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_UPLOAD_REPOSITORY_PORT = Symbol('ARTIFACT_UPLOAD_REPOSITORY_PORT'); + +export interface ArtifactUploadTransactionPortV1 { + save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise; + find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise; +} + +export interface ArtifactUploadRepositoryPortV1 extends ArtifactUploadTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactUploadTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts new file mode 100644 index 00000000..8a3a8214 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -0,0 +1,101 @@ +import { + abortArtifactUploadSessionV1, + completeArtifactUploadSessionV1, + createArtifactUploadSessionV1, + expireArtifactUploadSessionV1, + recordArtifactUploadPartV1, + type ArtifactUploadResultV1, + type ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactUploadRepositoryPortV1 } from './artifact-upload-repository.port.js'; + +export type ArtifactUploadServiceErrorV1 = 'UPLOAD_NOT_FOUND' | 'UPLOAD_SCOPE_NARROWING_REQUIRED'; +export type ArtifactUploadServiceResultV1 = + | ArtifactUploadResultV1 + | { readonly accepted: false; readonly code: ArtifactUploadServiceErrorV1 }; + +/** Coordinates revisioned upload state without accepting paths, URLs, or raw bytes. */ +export class ArtifactUploadService { + public constructor(private readonly repository: ArtifactUploadRepositoryPortV1) {} + + public async create( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createArtifactUploadSessionV1(input); + if (!created.accepted) return created; + if (!this.scopeAllowed(context, created.value)) + return Object.freeze({ accepted: false, code: 'UPLOAD_SCOPE_NARROWING_REQUIRED' as const }); + await this.repository.save(context, created.value); + return created; + } + + public async find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + return this.repository.find(context, sessionId); + } + + public async recordPart( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + input: Parameters[1], + ): Promise> { + return this.mutate(context, sessionId, (session) => recordArtifactUploadPartV1(session, input)); + } + + public async complete( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + input: Parameters[1], + ): Promise> { + return this.mutate(context, sessionId, (session) => + completeArtifactUploadSessionV1(session, input), + ); + } + + public async abort( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + expectedRevision: unknown, + ): Promise> { + return this.mutate(context, sessionId, (session) => + abortArtifactUploadSessionV1(session, expectedRevision), + ); + } + + public async expire( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + now: unknown, + ): Promise> { + return this.mutate(context, sessionId, (session) => + expireArtifactUploadSessionV1(session, now), + ); + } + + private async mutate( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + operation: ( + session: ArtifactUploadSessionV1, + ) => ArtifactUploadResultV1, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = operation(current); + if (!next.accepted) return next; + await transaction.save(context, next.value); + return next; + }); + } + + private scopeAllowed(context: IamTenantContextV1, session: ArtifactUploadSessionV1): boolean { + return tenantScopeContainsV1(context.tenantScope, session.tenantScope); + } +} diff --git a/services/api/test/features/iae/artifact-upload.service.test.ts b/services/api/test/features/iae/artifact-upload.service.test.ts new file mode 100644 index 00000000..a76016bc --- /dev/null +++ b/services/api/test/features/iae/artifact-upload.service.test.ts @@ -0,0 +1,53 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { ArtifactUploadService } from '../../../src/features/iae/application/artifact-upload.service.js'; +import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'upload-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +void test('IAE-014 service persists parts and rejects stale completion', async () => { + const service = new ArtifactUploadService(new InMemoryArtifactUploadRepositoryAdapter()); + const created = await service.create(context, { + sessionId: '55555555-5555-4555-8555-555555555555', + artifactId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const part = await service.recordPart(context, created.value.sessionId, { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:10:00.000Z', + expectedRevision: 1, + }); + assert.equal(part.accepted, true); + if (!part.accepted) return; + const completed = await service.complete(context, created.value.sessionId, { + assembledSha256: 'a'.repeat(64), + expectedRevision: 2, + }); + assert.equal(completed.accepted, true); + if (!completed.accepted) return; + assert.equal(completed.value.state, 'COMPLETED'); +}); From 3fbb5814990232b6ba933b31c829a14636754551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:40:32 +0700 Subject: [PATCH 22/74] feat(iae): persist resumable upload sessions --- .../migration.sql | 27 +++++++++++++++++++ services/api/prisma/schema/iae.prisma | 26 ++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql diff --git a/services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql b/services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql new file mode 100644 index 00000000..6d5b36ea --- /dev/null +++ b/services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql @@ -0,0 +1,27 @@ +CREATE TABLE "iae"."artifact_upload_sessions" ( + "id" UUID NOT NULL, + "artifact_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "expected_sha256" CHAR(64) NOT NULL, + "expected_byte_size" BIGINT NOT NULL, + "media_type" VARCHAR(255) NOT NULL, + "part_size" INTEGER NOT NULL, + "total_parts" INTEGER NOT NULL, + "parts" JSONB NOT NULL, + "state" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "artifact_upload_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "artifact_upload_sessions_artifact_idx" + ON "iae"."artifact_upload_sessions"("artifact_id"); +CREATE INDEX "artifact_upload_sessions_scope_state_idx" + ON "iae"."artifact_upload_sessions"("organization_id", "workspace_id", "project_id", "state"); +CREATE INDEX "artifact_upload_sessions_expiry_idx" + ON "iae"."artifact_upload_sessions"("expires_at"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index da4a5481..4ed5343f 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -165,3 +165,29 @@ model ArtifactExportManifestRecord { @@map("artifact_export_manifests") @@schema("iae") } + +/// IAE-014: resumable, content-addressed upload sessions with bounded part metadata. +model ArtifactUploadSessionRecord { + id String @id @db.Uuid + artifactId String @map("artifact_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + expectedSha256 String @map("expected_sha256") @db.Char(64) + expectedByteSize BigInt @map("expected_byte_size") + mediaType String @map("media_type") @db.VarChar(255) + partSize Int @map("part_size") + totalParts Int @map("total_parts") + parts Json + state String @db.VarChar(16) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([artifactId], map: "artifact_upload_sessions_artifact_idx") + @@index([organizationId, workspaceId, projectId, state], map: "artifact_upload_sessions_scope_state_idx") + @@index([expiresAt], map: "artifact_upload_sessions_expiry_idx") + @@map("artifact_upload_sessions") + @@schema("iae") +} From 270ceb1ce8396a749454e285f21715bb48c6ae50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:42:25 +0700 Subject: [PATCH 23/74] feat(iae): add Prisma upload session adapter --- ...isma-artifact-upload-repository.adapter.ts | 239 ++++++++++++++++++ .../prisma-artifact-upload-repository.test.ts | 85 +++++++ 2 files changed, 324 insertions(+) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-upload-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts new file mode 100644 index 00000000..69251dd1 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts @@ -0,0 +1,239 @@ +import { + abortArtifactUploadSessionV1, + completeArtifactUploadSessionV1, + createArtifactUploadSessionV1, + expireArtifactUploadSessionV1, + recordArtifactUploadPartV1, + type ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactUploadRepositoryPortV1, + ArtifactUploadTransactionPortV1, +} from '../application/artifact-upload-repository.port.js'; + +export interface ArtifactUploadDatabaseRowV1 { + readonly id: string; + readonly artifactId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly expectedSha256: string; + readonly expectedByteSize: bigint | number; + readonly mediaType: string; + readonly partSize: number; + readonly totalParts: number; + readonly parts: unknown; + readonly state: string; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ArtifactUploadDatabaseCreateDataV1 { + readonly id: string; + readonly artifactId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly expectedSha256: string; + readonly expectedByteSize: bigint; + readonly mediaType: string; + readonly partSize: number; + readonly totalParts: number; + readonly parts: unknown; + readonly state: string; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ArtifactUploadDatabaseClientV1 { + readonly artifactUploadSessionRecord: { + create(input: { + readonly data: ArtifactUploadDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly parts: unknown; readonly state: string; readonly revision: number }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactUploadDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: ArtifactUploadDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactUploadDatabaseRowV1): ArtifactUploadSessionV1 { + const expectedByteSize = + typeof row.expectedByteSize === 'bigint' ? Number(row.expectedByteSize) : row.expectedByteSize; + if (!Number.isSafeInteger(expectedByteSize) || expectedByteSize < 0) + throw new Error('IAE_PERSISTED_UPLOAD_SIZE_INVALID'); + const created = createArtifactUploadSessionV1({ + sessionId: row.id, + artifactId: row.artifactId, + tenantScope: rowScope(row), + expectedSha256: row.expectedSha256, + expectedByteSize, + mediaType: row.mediaType, + partSize: row.partSize, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + }); + if (!created.accepted) throw new Error('IAE_PERSISTED_UPLOAD_INVALID'); + if (!Array.isArray(row.parts)) throw new Error('IAE_PERSISTED_UPLOAD_PARTS_INVALID'); + let session = created.value; + for (const part of row.parts) { + if (typeof part !== 'object' || part === null || Array.isArray(part)) + throw new Error('IAE_PERSISTED_UPLOAD_PART_INVALID'); + const persistedPart = part as { + readonly partNumber: unknown; + readonly contentSha256: unknown; + readonly byteSize: unknown; + readonly uploadedAt: unknown; + }; + const next = recordArtifactUploadPartV1(session, { + ...persistedPart, + expectedRevision: session.revision, + }); + if (!next.accepted) throw new Error('IAE_PERSISTED_UPLOAD_PART_INVALID'); + session = next.value; + } + if (row.state === 'COMPLETED') { + const completed = completeArtifactUploadSessionV1(session, { + assembledSha256: row.expectedSha256, + expectedRevision: session.revision, + }); + if (!completed.accepted) throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + session = completed.value; + } else if (row.state === 'ABORTED') { + const aborted = abortArtifactUploadSessionV1(session, session.revision); + if (!aborted.accepted) throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + session = aborted.value; + } else if (row.state === 'EXPIRED') { + const expired = expireArtifactUploadSessionV1(session, row.expiresAt.toISOString()); + if (!expired.accepted) throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + session = expired.value; + } else if (row.state !== 'OPEN') { + throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + } + if (session.revision !== row.revision) throw new Error('IAE_PERSISTED_UPLOAD_REVISION_INVALID'); + return session; +} + +function domainToCreate(session: ArtifactUploadSessionV1): ArtifactUploadDatabaseCreateDataV1 { + return { + ...databaseScope(session.tenantScope), + id: session.sessionId, + artifactId: session.artifactId, + expectedSha256: session.expectedSha256, + expectedByteSize: BigInt(session.expectedByteSize), + mediaType: session.mediaType, + partSize: session.partSize, + totalParts: session.totalParts, + parts: session.parts, + state: session.state, + createdAt: new Date(session.createdAt), + expiresAt: new Date(session.expiresAt), + revision: session.revision, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactUploadDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactUploadTransactionAdapter implements ArtifactUploadTransactionPortV1 { + public constructor(private readonly client: ArtifactUploadDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactUploadSessionRecord.findUnique({ + where: { id: session.sessionId }, + }); + if (existing === null) { + await this.client.artifactUploadSessionRecord.create({ data: domainToCreate(session) }); + return; + } + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(session)) return; + if (session.revision !== current.revision + 1) throw new Error('IAE_UPLOAD_REVISION_CONFLICT'); + if ( + current.artifactId !== session.artifactId || + current.expectedSha256 !== session.expectedSha256 || + current.expectedByteSize !== session.expectedByteSize || + JSON.stringify(current.tenantScope) !== JSON.stringify(session.tenantScope) + ) + throw new Error('IAE_UPLOAD_IMMUTABLE_IDENTITY'); + await this.client.artifactUploadSessionRecord.update({ + where: { id: session.sessionId }, + data: { parts: session.parts, state: session.state, revision: session.revision }, + }); + } + + public async find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + const row = await this.client.artifactUploadSessionRecord.findUnique({ + where: { id: sessionId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } +} + +export class PrismaArtifactUploadRepositoryAdapter implements ArtifactUploadRepositoryPortV1 { + public constructor(private readonly client: ArtifactUploadDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactUploadTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactUploadTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + return new PrismaArtifactUploadTransactionAdapter(this.client).save(context, session); + } + + public find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + return new PrismaArtifactUploadTransactionAdapter(this.client).find(context, sessionId); + } +} diff --git a/services/api/test/features/iae/prisma-artifact-upload-repository.test.ts b/services/api/test/features/iae/prisma-artifact-upload-repository.test.ts new file mode 100644 index 00000000..97f5db23 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-upload-repository.test.ts @@ -0,0 +1,85 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + createArtifactUploadSessionV1, + recordArtifactUploadPartV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaArtifactUploadRepositoryAdapter, + type ArtifactUploadDatabaseClientV1, + type ArtifactUploadDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-upload-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-upload', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const created = createArtifactUploadSessionV1({ + sessionId: '55555555-5555-4555-8555-555555555555', + artifactId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', +}); +if (!created.accepted) throw new Error('fixture upload invalid'); +const part = recordArtifactUploadPartV1(created.value, { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:10:00.000Z', + expectedRevision: 1, +}); +if (!part.accepted) throw new Error('fixture part invalid'); + +function client(rows: ArtifactUploadDatabaseRowV1[]): ArtifactUploadDatabaseClientV1 { + return { + artifactUploadSessionRecord: { + create({ data }) { + const row = { + ...data, + expectedByteSize: data.expectedByteSize, + } as ArtifactUploadDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (!current) throw new Error('fixture upload not found'); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-014 Prisma upload adapter preserves parts, revisions, and immutable identity', async () => { + const rows: ArtifactUploadDatabaseRowV1[] = []; + const repository = new PrismaArtifactUploadRepositoryAdapter(client(rows)); + await repository.save(context, created.value); + await repository.save(context, created.value); + await repository.save(context, part.value); + assert.deepEqual(await repository.find(context, created.value.sessionId), part.value); + assert.equal(rows.length, 1); +}); From 1970f553b4a5f615ddbbcc32ac9df41dafb41c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:08:43 +0700 Subject: [PATCH 24/74] feat(iae): expose upload session control plane --- .../iae/api/artifact-upload.controller.ts | 104 ++++++++++++++++++ .../features/iae/api/artifact-upload.dto.ts | 82 ++++++++++++++ services/api/src/features/iae/iae.module.ts | 23 ++++ .../iae/artifact-upload.controller.test.ts | 51 +++++++++ 4 files changed, 260 insertions(+) create mode 100644 services/api/src/features/iae/api/artifact-upload.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-upload.dto.ts create mode 100644 services/api/test/features/iae/artifact-upload.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-upload.controller.ts b/services/api/src/features/iae/api/artifact-upload.controller.ts new file mode 100644 index 00000000..6b43131c --- /dev/null +++ b/services/api/src/features/iae/api/artifact-upload.controller.ts @@ -0,0 +1,104 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + ARTIFACT_UPLOAD_REPOSITORY_PORT, + type ArtifactUploadRepositoryPortV1, +} from '../application/artifact-upload-repository.port.js'; +import { ArtifactUploadService } from '../application/artifact-upload.service.js'; +import { + AbortArtifactUploadDto, + CompleteArtifactUploadDto, + CreateArtifactUploadSessionDto, + RecordArtifactUploadPartDto, +} from './artifact-upload.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-014: upload control-plane metadata only; bytes travel through a separately governed transfer adapter. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-upload-sessions') +export class ArtifactUploadController { + private readonly uploads: ArtifactUploadService; + + public constructor( + @Inject(ARTIFACT_UPLOAD_REPOSITORY_PORT) repository: ArtifactUploadRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.uploads = new ArtifactUploadService(repository); + } + + @Post() + @ApiOperation({ summary: 'Create a bounded resumable artifact upload session' }) + @ApiBody({ type: CreateArtifactUploadSessionDto }) + async create( + @Req() request: unknown, + @Body() input: CreateArtifactUploadSessionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.uploads.create(context, { ...input, tenantScope: context.tenantScope }); + } + + @Get(':sessionId') + @ApiOperation({ summary: 'Read upload session metadata and completed part digests' }) + async find( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + const session = await this.uploads.find(context, sessionId.value); + return session + ? Object.freeze({ accepted: true, value: session }) + : Object.freeze({ accepted: false, code: 'NOT_FOUND' }); + } + + @Post(':sessionId/parts') + @ApiOperation({ summary: 'Record one verified upload part digest' }) + @ApiBody({ type: RecordArtifactUploadPartDto }) + async part( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: RecordArtifactUploadPartDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.recordPart(context, sessionId.value, input); + } + + @Post(':sessionId/complete') + @ApiOperation({ + summary: 'Finalize an upload after all part digests and the assembled hash match', + }) + @ApiBody({ type: CompleteArtifactUploadDto }) + async complete( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: CompleteArtifactUploadDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.complete(context, sessionId.value, input); + } + + @Post(':sessionId/abort') + @ApiOperation({ summary: 'Abort an open upload session' }) + @ApiBody({ type: AbortArtifactUploadDto }) + async abort( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: AbortArtifactUploadDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.abort(context, sessionId.value, input.expectedRevision); + } +} diff --git a/services/api/src/features/iae/api/artifact-upload.dto.ts b/services/api/src/features/iae/api/artifact-upload.dto.ts new file mode 100644 index 00000000..6740e832 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-upload.dto.ts @@ -0,0 +1,82 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601, IsInt, IsNumber, IsString, IsUUID, Matches, Max, Min } from 'class-validator'; + +export class CreateArtifactUploadSessionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sessionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + artifactId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + expectedSha256!: string; + + @ApiProperty({ minimum: 0 }) + @IsNumber() + @Min(0) + expectedByteSize!: number; + + @ApiProperty() + @IsString() + mediaType!: string; + + @ApiProperty({ minimum: 1, maximum: 1073741824 }) + @IsInt() + @Min(1) + @Max(1073741824) + partSize!: number; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + expiresAt!: string; +} + +export class RecordArtifactUploadPartDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + partNumber!: number; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + contentSha256!: string; + + @ApiProperty({ minimum: 0 }) + @IsNumber() + @Min(0) + byteSize!: number; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + uploadedAt!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} + +export class CompleteArtifactUploadDto { + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + assembledSha256!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} + +export class AbortArtifactUploadDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 2d7f48cb..95c27832 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -7,6 +7,7 @@ import { ArtifactLineageController } from './api/artifact-lineage.controller.js' import { ContentPlacementController } from './api/content-placement.controller.js'; import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; import { ArtifactExportController } from './api/artifact-export.controller.js'; +import { ArtifactUploadController } from './api/artifact-upload.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -28,6 +29,11 @@ import { PrismaArtifactExportRepositoryAdapter, type ArtifactExportDatabaseClientV1, } from './adapter/prisma-artifact-export-repository.adapter.js'; +import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-artifact-upload-repository.adapter.js'; +import { + PrismaArtifactUploadRepositoryAdapter, + type ArtifactUploadDatabaseClientV1, +} from './adapter/prisma-artifact-upload-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -53,6 +59,10 @@ import { ARTIFACT_EXPORT_REPOSITORY_PORT, type ArtifactExportRepositoryPortV1, } from './application/artifact-export-repository.port.js'; +import { + ARTIFACT_UPLOAD_REPOSITORY_PORT, + type ArtifactUploadRepositoryPortV1, +} from './application/artifact-upload-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -79,6 +89,9 @@ export interface IaeModuleOptions { readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactExportDatabase?: ArtifactExportDatabaseClientV1; + readonly artifactUploadRepository?: ArtifactUploadRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -96,6 +109,7 @@ export class IaeModule { ContentPlacementController, ArtifactRetentionController, ArtifactExportController, + ArtifactUploadController, ], providers: [ { @@ -138,6 +152,14 @@ export class IaeModule { ? new InMemoryArtifactExportRepositoryAdapter() : new PrismaArtifactExportRepositoryAdapter(options.artifactExportDatabase)), }, + { + provide: ARTIFACT_UPLOAD_REPOSITORY_PORT, + useValue: + options.artifactUploadRepository ?? + (options.artifactUploadDatabase === undefined + ? new InMemoryArtifactUploadRepositoryAdapter() + : new PrismaArtifactUploadRepositoryAdapter(options.artifactUploadDatabase)), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), @@ -153,6 +175,7 @@ export class IaeModule { ARTIFACT_LINEAGE_REPOSITORY_PORT, ARTIFACT_RETENTION_REPOSITORY_PORT, ARTIFACT_EXPORT_REPOSITORY_PORT, + ARTIFACT_UPLOAD_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-upload.controller.test.ts b/services/api/test/features/iae/artifact-upload.controller.test.ts new file mode 100644 index 00000000..b98f9dc5 --- /dev/null +++ b/services/api/test/features/iae/artifact-upload.controller.test.ts @@ -0,0 +1,51 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'upload-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('IAE-014 upload HTTP control plane never accepts source bytes or paths', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactUploadRepository: new InMemoryArtifactUploadRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/artifact-upload-sessions', + payload: { + sessionId: '55555555-5555-4555-8555-555555555555', + artifactId: '66666666-6666-4666-8666-666666666666', + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', + }, + }); + assert.equal(response.statusCode, 201); + assert.doesNotMatch(response.body, /sourcePath|localPath|rawBytes|excerpt/iu); + } finally { + await app.close(); + } +}); From a077152b5bb405ba9770b4bc77212336f2b7b3a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:10:09 +0700 Subject: [PATCH 25/74] feat(iae): add Prisma evidence grant adapter --- ...risma-evidence-grant-repository.adapter.ts | 213 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 12 +- .../prisma-evidence-grant-repository.test.ts | 77 +++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-evidence-grant-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts new file mode 100644 index 00000000..e75f8324 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts @@ -0,0 +1,213 @@ +import type { + EvidenceAccessGrantV1, + EvidenceGrantActionV1, +} from '@databreeze/domain/evidence-grant/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + EvidenceGrantRepositoryPortV1, + EvidenceGrantTransactionPortV1, +} from '../application/evidence-grant-repository.port.js'; + +export interface EvidenceGrantDatabaseRowV1 { + readonly id: string; + readonly evidenceId: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly recipientDeviceId: string; + readonly action: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly authorizationEpoch: number; + readonly maxExcerptBytes: number; + readonly revokedAt: Date | null; +} + +export interface EvidenceGrantDatabaseClientV1 { + readonly evidenceGrantRecord: { + create(input: { + readonly data: Omit & { + readonly revokedAt: Date | null; + }; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly revokedAt: Date }; + }): Promise; + }; + $transaction( + work: (transaction: EvidenceGrantDatabaseClientV1) => Promise, + ): Promise; +} + +function scope(row: EvidenceGrantDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function id(input: string, error: string) { + const parsed = parseStableIdentifierV1(input); + if (!parsed.accepted) throw new Error(error); + return parsed.value; +} + +function timestamp(input: Date, error: string) { + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + if (!parsed.accepted) throw new Error(error); + return parsed.value; +} + +function rowToDomain(row: EvidenceGrantDatabaseRowV1): EvidenceAccessGrantV1 { + if (!['COORDINATE', 'EXCERPT', 'OPEN_ON_DEVICE'].includes(row.action)) + throw new Error('IAE_PERSISTED_GRANT_ACTION_INVALID'); + if ( + !Number.isSafeInteger(row.authorizationEpoch) || + row.authorizationEpoch < 1 || + !Number.isSafeInteger(row.maxExcerptBytes) || + row.maxExcerptBytes < 0 || + row.maxExcerptBytes > 4096 + ) + throw new Error('IAE_PERSISTED_GRANT_LIMIT_INVALID'); + return Object.freeze({ + schemaVersion: 1, + grantId: id(row.id, 'IAE_PERSISTED_GRANT_ID_INVALID'), + evidenceId: id(row.evidenceId, 'IAE_PERSISTED_GRANT_ID_INVALID'), + artifactVersionId: id(row.artifactVersionId, 'IAE_PERSISTED_GRANT_ID_INVALID'), + tenantScope: scope(row), + recipientDeviceId: id(row.recipientDeviceId, 'IAE_PERSISTED_GRANT_ID_INVALID'), + action: row.action as EvidenceGrantActionV1, + issuedAt: timestamp(row.issuedAt, 'IAE_PERSISTED_GRANT_TIMESTAMP_INVALID'), + expiresAt: timestamp(row.expiresAt, 'IAE_PERSISTED_GRANT_TIMESTAMP_INVALID'), + authorizationEpoch: row.authorizationEpoch, + maxExcerptBytes: row.maxExcerptBytes, + }); +} + +function databaseScope(scopeValue: TenantScopeV1) { + return { + scopeType: scopeValue.scopeType, + organizationId: scopeValue.organizationId, + workspaceId: scopeValue.scopeType === 'organization' ? null : scopeValue.workspaceId, + projectId: scopeValue.scopeType === 'project' ? scopeValue.projectId : null, + } as const; +} + +function visible(context: TenantScopeV1, row: EvidenceGrantDatabaseRowV1): boolean { + const candidate = scope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaEvidenceGrantTransactionAdapter implements EvidenceGrantTransactionPortV1 { + public constructor(private readonly client: EvidenceGrantDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, grant.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.evidenceGrantRecord.findUnique({ + where: { id: grant.grantId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(grant)) + throw new Error('IAE_IMMUTABLE_GRANT'); + return; + } + await this.client.evidenceGrantRecord.create({ + data: { + ...databaseScope(grant.tenantScope), + id: grant.grantId, + evidenceId: grant.evidenceId, + artifactVersionId: grant.artifactVersionId, + recipientDeviceId: grant.recipientDeviceId, + action: grant.action, + issuedAt: new Date(grant.issuedAt), + expiresAt: new Date(grant.expiresAt), + authorizationEpoch: grant.authorizationEpoch, + maxExcerptBytes: grant.maxExcerptBytes, + revokedAt: null, + }, + }); + } + + public async find( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + const row = await this.client.evidenceGrantRecord.findUnique({ where: { id: grantId } }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } + + public async revoke( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + const row = await this.client.evidenceGrantRecord.findUnique({ where: { id: grantId } }); + if (row === null || !visible(context.tenantScope, row)) throw new Error('IAE_GRANT_NOT_FOUND'); + if (row.revokedAt !== null) return; + await this.client.evidenceGrantRecord.update({ + where: { id: grantId }, + data: { revokedAt: new Date() }, + }); + } + + public async isRevoked( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + const row = await this.client.evidenceGrantRecord.findUnique({ where: { id: grantId } }); + return row !== null && visible(context.tenantScope, row) && row.revokedAt !== null; + } +} + +export class PrismaEvidenceGrantRepositoryAdapter implements EvidenceGrantRepositoryPortV1 { + public constructor(private readonly client: EvidenceGrantDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: EvidenceGrantTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaEvidenceGrantTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).save(context, grant); + } + public find( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).find(context, grantId); + } + public revoke( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).revoke(context, grantId); + } + public isRevoked( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).isRevoked(context, grantId); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 95c27832..7f27322a 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -39,6 +39,10 @@ import { type ArtifactDatabaseClientV1, } from './adapter/prisma-artifact-repository.adapter.js'; import { InMemoryEvidenceGrantRepositoryAdapter } from './adapter/in-memory-evidence-grant-repository.adapter.js'; +import { + PrismaEvidenceGrantRepositoryAdapter, + type EvidenceGrantDatabaseClientV1, +} from './adapter/prisma-evidence-grant-repository.adapter.js'; import { ARTIFACT_INTAKE_REPOSITORY_PORT, type ArtifactIntakeRepositoryPortV1, @@ -93,6 +97,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly evidenceGrantDatabase?: EvidenceGrantDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -162,7 +168,11 @@ export class IaeModule { }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, - useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), + useValue: + options.evidenceGrantRepository ?? + (options.evidenceGrantDatabase === undefined + ? new InMemoryEvidenceGrantRepositoryAdapter() + : new PrismaEvidenceGrantRepositoryAdapter(options.evidenceGrantDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/iae/prisma-evidence-grant-repository.test.ts b/services/api/test/features/iae/prisma-evidence-grant-repository.test.ts new file mode 100644 index 00000000..d4159c7b --- /dev/null +++ b/services/api/test/features/iae/prisma-evidence-grant-repository.test.ts @@ -0,0 +1,77 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createEvidenceAccessGrantV1 } from '@databreeze/domain/evidence-grant/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaEvidenceGrantRepositoryAdapter, + type EvidenceGrantDatabaseClientV1, + type EvidenceGrantDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-evidence-grant-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-grant', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const grantResult = createEvidenceAccessGrantV1({ + grantId: '55555555-5555-4555-8555-555555555555', + evidenceId: '66666666-6666-4666-8666-666666666666', + artifactVersionId: '77777777-7777-4777-8777-777777777777', + tenantScope: context.tenantScope, + recipientDeviceId: '88888888-8888-4888-8888-888888888888', + action: 'COORDINATE', + issuedAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T00:05:00.000Z', + authorizationEpoch: 1, + artifactDataMode: 'Hybrid', + sourceState: 'AVAILABLE', +}); +if (!grantResult.accepted) throw new Error('fixture grant invalid'); +const grant = grantResult.value; + +function client(rows: EvidenceGrantDatabaseRowV1[]): EvidenceGrantDatabaseClientV1 { + return { + evidenceGrantRecord: { + create({ data }) { + const row = { ...data } as EvidenceGrantDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const row = rows.find((candidate) => candidate.id === where.id); + if (!row) throw new Error('fixture grant not found'); + const next = { ...row, ...data }; + rows[rows.indexOf(row)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-005 Prisma grant adapter persists immutable grants and revocation', async () => { + const rows: EvidenceGrantDatabaseRowV1[] = []; + const repository = new PrismaEvidenceGrantRepositoryAdapter(client(rows)); + await repository.save(context, grant); + await repository.save(context, grant); + assert.deepEqual(await repository.find(context, grant.grantId), grant); + assert.equal(await repository.isRevoked(context, grant.grantId), false); + await repository.revoke(context, grant.grantId); + await repository.revoke(context, grant.grantId); + assert.equal(await repository.isRevoked(context, grant.grantId), true); + assert.equal(rows.length, 1); +}); From 0bb9391a0930189167f299e78ac995c4c8d61a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:11:34 +0700 Subject: [PATCH 26/74] fix(dsm): enforce dataset result input validation --- .../features/dsm/api/dataset-version.dto.ts | 13 ++++++-- .../dsm/dataset-version.controller.test.ts | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/dsm/api/dataset-version.dto.ts b/services/api/src/features/dsm/api/dataset-version.dto.ts index 4c6b80b5..06d95892 100644 --- a/services/api/src/features/dsm/api/dataset-version.dto.ts +++ b/services/api/src/features/dsm/api/dataset-version.dto.ts @@ -5,8 +5,11 @@ import { IsIn, IsInt, IsISO8601, + IsString, IsUUID, Max, + MaxLength, + Matches, Min, MinLength, } from 'class-validator'; @@ -35,15 +38,18 @@ export class RegisterDatasetVersionDto { ruleSetVersionId!: string; @ApiProperty({ minLength: 1, maxLength: 128 }) + @IsString() @MinLength(1) + @MaxLength(128) engineBuild!: string; - @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @ApiProperty({ format: 'uuid' }) @IsUUID() versionId!: string; @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) - @MinLength(64) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) contentFingerprint!: string; @ApiProperty({ minimum: 0 }) @@ -57,6 +63,7 @@ export class RegisterDatasetVersionDto { qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) - @MinLength(64) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) lineageManifestHash!: string; } diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts index fbaba935..bb054bb4 100644 --- a/services/api/test/features/dsm/dataset-version.controller.test.ts +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -61,3 +61,35 @@ void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable an await app.close(); } }); + +void test('[DSM-002] dataset result DTO rejects malformed hashes and non-UUID version identities', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context()), + }; + const { app } = await createApiApplication({ + datasetVersionRepository: new InMemoryDatasetVersionRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-versions', + payload: { + versionId: 'not-a-uuid', + datasetId, + inputArtifactVersionIds: [], + schemaVersionId: '00000000-0000-4000-8000-000000000808', + mappingVersionId: '00000000-0000-4000-8000-000000000809', + ruleSetVersionId: '00000000-0000-4000-8000-000000000810', + engineBuild: 'engine@1', + contentFingerprint: 'not-a-hash', + rowCount: 0, + qualityState: 'PASS', + lineageManifestHash: 'not-a-hash', + }, + }); + assert.equal(response.statusCode, 400); + } finally { + await app.close(); + } +}); From 11bb7b7dfba5e7e50b3c56eb174a5bd5f45e4a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:13:46 +0700 Subject: [PATCH 27/74] feat(dsm): list dataset result manifests --- ...mory-dataset-version-repository.adapter.ts | 20 ++++++++++++++++- ...isma-dataset-version-repository.adapter.ts | 22 +++++++++++++++++++ .../dsm/api/dataset-version.controller.ts | 14 +++++++++++- .../dataset-version-repository.port.ts | 4 ++++ .../application/dataset-version.service.ts | 9 ++++++++ .../dsm/dataset-version.controller.test.ts | 6 +++++ .../prisma-dataset-version-repository.test.ts | 12 ++++++++++ 7 files changed, 85 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts index 6517e8d1..de02d3fb 100644 --- a/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts @@ -44,6 +44,20 @@ export class InMemoryDatasetVersionRepositoryAdapter implements DatasetVersionRe : undefined; } + public async list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + await Promise.resolve(); + return [...this.versions.values()] + .filter( + (version) => + version.datasetId === datasetId && visible(context.tenantScope, version.tenantScope), + ) + .sort((left, right) => left.versionId.localeCompare(right.versionId)) + .map(clone); + } + public async withTransaction( context: IamTenantContextV1, work: (transaction: DatasetVersionTransactionPortV1) => Promise, @@ -56,7 +70,11 @@ export class InMemoryDatasetVersionRepositoryAdapter implements DatasetVersionRe await previous; const before = new Map(this.versions); try { - return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); } catch (error) { this.versions = before; throw error; diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts index 368ebbeb..55cb6c03 100644 --- a/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts @@ -47,6 +47,10 @@ export interface DatasetVersionDatabaseClientV1 { findUnique(input: { readonly where: { readonly id: string }; }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; }; $transaction( work: (transaction: DatasetVersionDatabaseClientV1) => Promise, @@ -143,6 +147,17 @@ class PrismaDatasetVersionTransactionAdapter implements DatasetVersionTransactio ? rowToDomain(row) : undefined; } + + public async list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + const rows = await this.client.datasetVersionRecord.findMany({ + where: { datasetId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } } export class PrismaDatasetVersionRepositoryAdapter implements DatasetVersionRepositoryPortV1 { @@ -167,4 +182,11 @@ export class PrismaDatasetVersionRepositoryAdapter implements DatasetVersionRepo ): Promise { return new PrismaDatasetVersionTransactionAdapter(this.client).find(context, versionId); } + + public list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + return new PrismaDatasetVersionTransactionAdapter(this.client).list(context, datasetId); + } } diff --git a/services/api/src/features/dsm/api/dataset-version.controller.ts b/services/api/src/features/dsm/api/dataset-version.controller.ts index 87ce6576..d26f2911 100644 --- a/services/api/src/features/dsm/api/dataset-version.controller.ts +++ b/services/api/src/features/dsm/api/dataset-version.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -50,4 +50,16 @@ export class DatasetVersionController { if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.versions.find(context, versionId.value); } + + @Get() + @ApiOperation({ summary: 'List exact dataset result manifests for one governed dataset' }) + async list( + @Req() request: unknown, + @Query('datasetId') datasetIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.versions.list(context, datasetId.value); + } } diff --git a/services/api/src/features/dsm/application/dataset-version-repository.port.ts b/services/api/src/features/dsm/application/dataset-version-repository.port.ts index 2acc2085..7cfbaf93 100644 --- a/services/api/src/features/dsm/application/dataset-version-repository.port.ts +++ b/services/api/src/features/dsm/application/dataset-version-repository.port.ts @@ -10,6 +10,10 @@ export interface DatasetVersionTransactionPortV1 { context: IamTenantContextV1, versionId: DatasetVersionManifestV1['versionId'], ): Promise; + list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise; } export interface DatasetVersionRepositoryPortV1 extends DatasetVersionTransactionPortV1 { diff --git a/services/api/src/features/dsm/application/dataset-version.service.ts b/services/api/src/features/dsm/application/dataset-version.service.ts index e331f087..11239575 100644 --- a/services/api/src/features/dsm/application/dataset-version.service.ts +++ b/services/api/src/features/dsm/application/dataset-version.service.ts @@ -42,4 +42,13 @@ export class DatasetVersionService { ? Object.freeze({ accepted: true, value: found }) : Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); } + + public async list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetId), + ); + } } diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts index bb054bb4..6608e52b 100644 --- a/services/api/test/features/dsm/dataset-version.controller.test.ts +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -57,6 +57,12 @@ void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable an const read = await app.inject({ method: 'GET', url: `/v1/dataset-versions/${versionId}` }); assert.equal(read.statusCode, 200); assert.equal(read.json().value.contentFingerprint, 'a'.repeat(64)); + const listed = await app.inject({ + method: 'GET', + url: `/v1/dataset-versions?datasetId=${datasetId}`, + }); + assert.equal(listed.statusCode, 200); + assert.equal(listed.json().length, 1); } finally { await app.close(); } diff --git a/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts index ecf4cbdf..89fb7262 100644 --- a/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts +++ b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts @@ -48,6 +48,17 @@ function client(rows: DatasetVersionDatabaseRowV1[]): DatasetVersionDatabaseClie findUnique({ where }) { return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.datasetId === where['datasetId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, }, $transaction(work) { return work(this); @@ -78,5 +89,6 @@ void test('[DSM-002, DSM-003, IAM-009] Prisma dataset version adapter is immutab await repository.save(tenantContext, created.value); await repository.save(tenantContext, created.value); assert.deepEqual(await repository.find(tenantContext, versionId), created.value); + assert.deepEqual(await repository.list(tenantContext, created.value.datasetId), [created.value]); assert.equal(rows.length, 1); }); From 7da9117e7691bdbe78dde485918ff1c9ef11a85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:15:47 +0700 Subject: [PATCH 28/74] feat(iae): persist artifact admission status --- .../in-memory-artifact-repository.adapter.ts | 20 +++++++++++ .../prisma-artifact-repository.adapter.ts | 36 +++++++++++++++++++ .../application/artifact-repository.port.ts | 5 +++ .../iae/prisma-artifact-repository.test.ts | 13 +++++++ 4 files changed, 74 insertions(+) diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts index e3cc3d84..1315c91a 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts @@ -64,6 +64,25 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort : undefined; } + async updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise { + await Promise.resolve(); + const current = this.versions.get(versionId); + if (!current || !visibleInScope(context.tenantScope, current.tenantScope)) return undefined; + if (!scopeAllowsMutation(context, current.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + if (!['QUARANTINED', 'ACTIVE', 'DELETED'].includes(status)) + throw new Error('IAE_INVALID_STATUS'); + if (current.status === 'DELETED' && status !== 'DELETED') + throw new Error('IAE_TERMINAL_STATUS'); + const next = cloneVersion({ ...current, status }); + this.versions.set(versionId, next); + return next; + } + async savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { await Promise.resolve(); if (!scopeAllowsMutation(context, placement.tenantScope)) @@ -152,6 +171,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort return await work({ saveVersion: this.saveVersion.bind(this), findVersion: this.findVersion.bind(this), + updateVersionStatus: this.updateVersionStatus.bind(this), savePlacement: this.savePlacement.bind(this), updatePlacement: this.updatePlacement.bind(this), listPlacements: this.listPlacements.bind(this), diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index e87e9cd9..b0ff651e 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -82,6 +82,10 @@ export interface ArtifactDatabaseClientV1 { findUnique(input: { readonly where: { readonly id: string }; }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly status: string }; + }): Promise; }; readonly contentPlacement: { create(input: { @@ -235,6 +239,27 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { : undefined; } + public async updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise { + const row = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); + if (row === null || !visible(context.tenantScope, row)) return undefined; + if (!tenantScopeContainsV1(context.tenantScope, rowScope(row))) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const current = rowToVersion(row); + if (!['QUARANTINED', 'ACTIVE', 'DELETED'].includes(status)) + throw new Error('IAE_INVALID_STATUS'); + if (current.status === 'DELETED' && status !== 'DELETED') + throw new Error('IAE_TERMINAL_STATUS'); + const updated = await this.client.artifactVersion.update({ + where: { id: versionId }, + data: { status }, + }); + return rowToVersion(updated); + } + public async savePlacement( context: IamTenantContextV1, placement: ContentPlacementV1, @@ -372,6 +397,17 @@ export class PrismaArtifactRepositoryAdapter implements ArtifactRepositoryPortV1 ): Promise { return new PrismaArtifactTransactionAdapter(this.client).findVersion(context, versionId); } + public updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise { + return new PrismaArtifactTransactionAdapter(this.client).updateVersionStatus( + context, + versionId, + status, + ); + } public savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { return new PrismaArtifactTransactionAdapter(this.client).savePlacement(context, placement); } diff --git a/services/api/src/features/iae/application/artifact-repository.port.ts b/services/api/src/features/iae/application/artifact-repository.port.ts index 0db52f18..4a2e768c 100644 --- a/services/api/src/features/iae/application/artifact-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-repository.port.ts @@ -14,6 +14,11 @@ export interface ArtifactTransactionPortV1 { context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], ): Promise; + updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise; savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; listPlacements( diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index 95fddcc9..0f4ec5f6 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -63,6 +63,13 @@ function client( versions.find((candidate) => candidate.id === input.where.id) ?? null, ); }, + update(input) { + const current = versions.find((candidate) => candidate.id === input.where.id); + if (!current) throw new Error('fixture version not found'); + const next = { ...current, ...input.data }; + versions[versions.indexOf(current)] = next; + return Promise.resolve(next); + }, }, contentPlacement: { create(input) { @@ -149,6 +156,12 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl const evidence: EvidenceDatabaseRowV1[] = []; const repository = new PrismaArtifactRepositoryAdapter(client([], placements, evidence)); await repository.saveVersion(context('version'), artifact.value); + const quarantined = await repository.updateVersionStatus( + context('quarantine'), + versionId, + 'QUARANTINED', + ); + assert.equal(quarantined?.status, 'QUARANTINED'); await repository.savePlacement(context('placement'), placement.value); await repository.savePlacement(context('placement-repeat'), placement.value); await repository.saveEvidence(context('evidence'), evidenceRef.value); From ad9857bccd9c74bfaa35925c97f3d544910c6f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:16:45 +0700 Subject: [PATCH 29/74] feat(iae): coordinate artifact admission --- .../application/artifact-admission.service.ts | 49 +++++++++++++++ .../iae/artifact-admission.service.test.ts | 60 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 services/api/src/features/iae/application/artifact-admission.service.ts create mode 100644 services/api/test/features/iae/artifact-admission.service.test.ts diff --git a/services/api/src/features/iae/application/artifact-admission.service.ts b/services/api/src/features/iae/application/artifact-admission.service.ts new file mode 100644 index 00000000..34219e8a --- /dev/null +++ b/services/api/src/features/iae/application/artifact-admission.service.ts @@ -0,0 +1,49 @@ +import { + finalizeArtifactAdmissionV1, + type ArtifactIntakeResultV1, + type ArtifactScanStateV1, +} from '@databreeze/domain/artifact-intake/v1'; +import type { ArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; + +export type ArtifactAdmissionServiceErrorV1 = 'ARTIFACT_NOT_FOUND' | 'ADMISSION_UPDATE_FAILED'; +export type ArtifactAdmissionServiceResultV1 = + | ArtifactIntakeResultV1 + | { readonly accepted: false; readonly code: ArtifactAdmissionServiceErrorV1 }; + +/** IAE-009/010: validates scanner output, then records only the governed status projection. */ +export class ArtifactAdmissionService { + public constructor(private readonly repository: ArtifactRepositoryPortV1) {} + + public async admit( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + input: Omit[0], 'artifact'>, + ): Promise< + ArtifactAdmissionServiceResultV1<{ + readonly version: ArtifactVersionV1; + readonly status: 'ACTIVE' | 'QUARANTINED'; + readonly scanState: ArtifactScanStateV1; + }> + > { + return this.repository.withTransaction(context, async (transaction) => { + const artifact = await transaction.findVersion(context, versionId); + if (!artifact) return Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + const admission = finalizeArtifactAdmissionV1({ artifact, ...input }); + if (!admission.accepted) return admission; + const updated = await transaction.updateVersionStatus( + context, + versionId, + admission.value.status, + ); + if (!updated) + return Object.freeze({ accepted: false, code: 'ADMISSION_UPDATE_FAILED' as const }); + return Object.freeze({ + accepted: true, + value: Object.freeze({ version: updated, ...admission.value }), + }); + }); + } +} diff --git a/services/api/test/features/iae/artifact-admission.service.test.ts b/services/api/test/features/iae/artifact-admission.service.test.ts new file mode 100644 index 00000000..70d96704 --- /dev/null +++ b/services/api/test/features/iae/artifact-admission.service.test.ts @@ -0,0 +1,60 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; +import { ArtifactAdmissionService } from '../../../src/features/iae/application/artifact-admission.service.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'admission-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +void test('IAE-009/010 admission updates only the status projection after scanner checks', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const service = new ArtifactAdmissionService(repository); + const artifact = createArtifactVersionV1({ + artifactId: '55555555-5555-4555-8555-555555555555', + versionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 4, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-08-02T00:00:00.000Z', + status: 'QUARANTINED', + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) return; + await repository.saveVersion(context, artifact.value); + const admitted = await service.admit(context, artifact.value.versionId, { + actualSha256: 'a'.repeat(64), + actualByteSize: 4, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + assert.equal(admitted.value.version.status, 'ACTIVE'); + const rejected = await service.admit(context, artifact.value.versionId, { + actualSha256: 'b'.repeat(64), + actualByteSize: 4, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }); + assert.deepEqual(rejected, { accepted: false, code: 'DIGEST_MISMATCH' }); +}); From f54d096c97f938d1d6f430470cd9ea6d18a1c603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:19:38 +0700 Subject: [PATCH 30/74] feat(iae): expose artifact admission endpoint --- .../iae/api/artifact-admission.controller.ts | 45 +++++++++++++ .../iae/api/artifact-admission.dto.ts | 40 +++++++++++ services/api/src/features/iae/iae.module.ts | 2 + .../iae/artifact-admission.controller.test.ts | 67 +++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 services/api/src/features/iae/api/artifact-admission.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-admission.dto.ts create mode 100644 services/api/test/features/iae/artifact-admission.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-admission.controller.ts b/services/api/src/features/iae/api/artifact-admission.controller.ts new file mode 100644 index 00000000..c25cf45e --- /dev/null +++ b/services/api/src/features/iae/api/artifact-admission.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ArtifactAdmissionService } from '../application/artifact-admission.service.js'; +import { AdmitArtifactDto } from './artifact-admission.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-009/010: admission accepts verifier metadata, never source bytes or executable content. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ArtifactAdmissionController { + private readonly admission: ArtifactAdmissionService; + + public constructor( + @Inject(ARTIFACT_REPOSITORY_PORT) repository: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.admission = new ArtifactAdmissionService(repository); + } + + @Post(':versionId/admit') + @ApiOperation({ + summary: 'Admit an exact artifact version after digest, media, size, and scan checks', + }) + @ApiBody({ type: AdmitArtifactDto }) + async admit( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + @Body() input: AdmitArtifactDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.admission.admit(context, versionId.value, input); + } +} diff --git a/services/api/src/features/iae/api/artifact-admission.dto.ts b/services/api/src/features/iae/api/artifact-admission.dto.ts new file mode 100644 index 00000000..c79647e2 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-admission.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsISO8601, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Min, + Matches, +} from 'class-validator'; + +export class AdmitArtifactDto { + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + actualSha256!: string; + + @ApiProperty({ minimum: 0 }) + @IsNumber() + @Min(0) + actualByteSize!: number; + + @ApiProperty() + @IsString() + detectedMediaType!: string; + + @ApiProperty({ enum: ['PENDING', 'CLEAN', 'MALICIOUS', 'FAILED'] }) + @IsIn(['PENDING', 'CLEAN', 'MALICIOUS', 'FAILED']) + scanState!: 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + maxByteSize!: number; + + @ApiProperty({ format: 'date-time', required: false }) + @IsOptional() + @IsISO8601() + scannedAt?: string; +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 7f27322a..54a640ac 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -8,6 +8,7 @@ import { ContentPlacementController } from './api/content-placement.controller.j import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; import { ArtifactExportController } from './api/artifact-export.controller.js'; import { ArtifactUploadController } from './api/artifact-upload.controller.js'; +import { ArtifactAdmissionController } from './api/artifact-admission.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -116,6 +117,7 @@ export class IaeModule { ArtifactRetentionController, ArtifactExportController, ArtifactUploadController, + ArtifactAdmissionController, ], providers: [ { diff --git a/services/api/test/features/iae/artifact-admission.controller.test.ts b/services/api/test/features/iae/artifact-admission.controller.test.ts new file mode 100644 index 00000000..197a7954 --- /dev/null +++ b/services/api/test/features/iae/artifact-admission.controller.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { createArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'admit-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('IAE-009/010 admission HTTP endpoint persists clean status without source content', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const artifact = createArtifactVersionV1({ + artifactId: '55555555-5555-4555-8555-555555555555', + versionId: '66666666-6666-4666-8666-666666666666', + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 4, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-08-02T00:00:00.000Z', + status: 'QUARANTINED', + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) return; + await repository.saveVersion(tenantContext, artifact.value); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/artifact-versions/${artifact.value.versionId}/admit`, + payload: { + actualSha256: 'a'.repeat(64), + actualByteSize: 4, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }, + }); + assert.equal(response.statusCode, 201); + assert.equal(response.json().value.version.status, 'ACTIVE'); + assert.doesNotMatch(response.body, /sourcePath|rawBytes|excerpt/iu); + } finally { + await app.close(); + } +}); From a860c915d4bedecb31f3d1e816fedbd4ef929d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:20:29 +0700 Subject: [PATCH 31/74] fix(iae): normalize retention adapter formatting --- .../in-memory-artifact-retention-repository.adapter.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts index 73624c5e..ab9cee7c 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts @@ -1,7 +1,4 @@ -import { - tenantScopeContainsV1, - type TenantScopeV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { ArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -55,7 +52,9 @@ export class InMemoryArtifactRetentionRepositoryAdapter ): Promise { await Promise.resolve(); const request = this.requests.get(requestId); - return request && visible(context.tenantScope, request.tenantScope) ? clone(request) : undefined; + return request && visible(context.tenantScope, request.tenantScope) + ? clone(request) + : undefined; } public async withTransaction( From f496647771135f8fc6af31c5202e8f58b00c643a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:26:59 +0700 Subject: [PATCH 32/74] fix(test): satisfy strict API lint gates --- packages/domain/src/artifact-upload/v1.ts | 5 ---- .../features/dsm/api/dataset-version.dto.ts | 1 - .../application/content-placement.service.ts | 5 +--- .../dsm/dataset-version.controller.test.ts | 13 +++++++--- .../dsm/governed-dataset.controller.test.ts | 8 ++++-- .../features/dsm/mapping.controller.test.ts | 3 ++- .../dsm/reference-entity.controller.test.ts | 3 ++- .../features/dsm/rule-set.controller.test.ts | 3 ++- .../iae/artifact-admission.controller.test.ts | 5 +++- .../iae/artifact-lineage.controller.test.ts | 8 ++++-- .../iae/artifact-read.controller.test.ts | 13 ++++++++-- .../prisma-artifact-export-repository.test.ts | 26 +++++++++++-------- 12 files changed, 59 insertions(+), 34 deletions(-) diff --git a/packages/domain/src/artifact-upload/v1.ts b/packages/domain/src/artifact-upload/v1.ts index 9f719329..b1e37cc2 100644 --- a/packages/domain/src/artifact-upload/v1.ts +++ b/packages/domain/src/artifact-upload/v1.ts @@ -89,10 +89,6 @@ function positiveInteger(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 ? input : undefined; } -function revision(input: unknown): number | undefined { - return positiveInteger(input); -} - function validPart(part: unknown, totalParts: number): part is ArtifactUploadPartV1 { if (typeof part !== 'object' || part === null || Array.isArray(part)) return false; const record = part as Record; @@ -124,7 +120,6 @@ export function createArtifactUploadSessionV1(input: { const artifactId = identifier(input.artifactId); const tenantScope = parseTenantScopeV1(input.tenantScope); const expectedSha256 = hash(input.expectedSha256); - const expectedByteSize = positiveInteger(input.expectedByteSize ?? 0); const partSize = positiveInteger(input.partSize); const mediaTypeValue = mediaType(input.mediaType); const createdAt = timestamp(input.createdAt); diff --git a/services/api/src/features/dsm/api/dataset-version.dto.ts b/services/api/src/features/dsm/api/dataset-version.dto.ts index 06d95892..ba6b0cc5 100644 --- a/services/api/src/features/dsm/api/dataset-version.dto.ts +++ b/services/api/src/features/dsm/api/dataset-version.dto.ts @@ -4,7 +4,6 @@ import { IsArray, IsIn, IsInt, - IsISO8601, IsString, IsUUID, Max, diff --git a/services/api/src/features/iae/application/content-placement.service.ts b/services/api/src/features/iae/application/content-placement.service.ts index 4ad9c0f3..1701cb85 100644 --- a/services/api/src/features/iae/application/content-placement.service.ts +++ b/services/api/src/features/iae/application/content-placement.service.ts @@ -1,7 +1,4 @@ -import { - parseStableIdentifierV1, - type StableIdentifierV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { updateContentPlacementAvailabilityV1, type ArtifactResultV1, diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts index 6608e52b..0cc84061 100644 --- a/services/api/test/features/dsm/dataset-version.controller.test.ts +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -53,16 +53,23 @@ void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable an }, }); assert.equal(response.statusCode, 201); - assert.equal(response.json().value.rowCount, 42); + const createdBody = JSON.parse(response.body) as { + readonly value: { readonly rowCount: number }; + }; + assert.equal(createdBody.value.rowCount, 42); const read = await app.inject({ method: 'GET', url: `/v1/dataset-versions/${versionId}` }); assert.equal(read.statusCode, 200); - assert.equal(read.json().value.contentFingerprint, 'a'.repeat(64)); + const readBody = JSON.parse(read.body) as { + readonly value: { readonly contentFingerprint: string }; + }; + assert.equal(readBody.value.contentFingerprint, 'a'.repeat(64)); const listed = await app.inject({ method: 'GET', url: `/v1/dataset-versions?datasetId=${datasetId}`, }); assert.equal(listed.statusCode, 200); - assert.equal(listed.json().length, 1); + const listedBody = JSON.parse(listed.body) as readonly unknown[]; + assert.equal(listedBody.length, 1); } finally { await app.close(); } diff --git a/services/api/test/features/dsm/governed-dataset.controller.test.ts b/services/api/test/features/dsm/governed-dataset.controller.test.ts index f0cea603..985028ce 100644 --- a/services/api/test/features/dsm/governed-dataset.controller.test.ts +++ b/services/api/test/features/dsm/governed-dataset.controller.test.ts @@ -64,13 +64,17 @@ void test('[DSM-005, DSM-006, DSM-018, DSM-021] governed dataset HTTP surfaces p }, }); assert.equal(published.statusCode, 200); - assert.equal(published.json().value.status, 'PUBLISHED'); + const publishedBody = JSON.parse(published.body) as { + readonly value: { readonly status: string }; + }; + assert.equal(publishedBody.value.status, 'PUBLISHED'); const read = await app.inject({ method: 'GET', url: `/v1/datasets/${datasetId}/versions/${publishedVersionId}`, }); assert.equal(read.statusCode, 200); - assert.equal(read.json().value.versionId, publishedVersionId); + const readBody = JSON.parse(read.body) as { readonly value: { readonly versionId: string } }; + assert.equal(readBody.value.versionId, publishedVersionId); const comparison = await app.inject({ method: 'GET', url: `/v1/datasets/${datasetId}/compatibility?previousVersionId=${versionId}&nextVersionId=${publishedVersionId}`, diff --git a/services/api/test/features/dsm/mapping.controller.test.ts b/services/api/test/features/dsm/mapping.controller.test.ts index 5646ba24..9aee3882 100644 --- a/services/api/test/features/dsm/mapping.controller.test.ts +++ b/services/api/test/features/dsm/mapping.controller.test.ts @@ -61,7 +61,8 @@ void test('[DSM-009, DSM-010, DSM-021] mapping publication is exposed as an immu payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, }); assert.equal(response.statusCode, 200); - assert.equal(response.json().value.status, 'PUBLISHED'); + const body = JSON.parse(response.body) as { readonly value: { readonly status: string } }; + assert.equal(body.value.status, 'PUBLISHED'); } finally { await app.close(); } diff --git a/services/api/test/features/dsm/reference-entity.controller.test.ts b/services/api/test/features/dsm/reference-entity.controller.test.ts index 9748a4e7..f006a692 100644 --- a/services/api/test/features/dsm/reference-entity.controller.test.ts +++ b/services/api/test/features/dsm/reference-entity.controller.test.ts @@ -54,7 +54,8 @@ void test('[DSM-025, DSM-026, DSM-027] reference entity API exposes exact versio url: `/v1/reference-entities/${entityId}/versions/${versionId}`, }); assert.equal(response.statusCode, 200); - assert.equal(response.json().value.displayName, 'Công ty Ánh Dương'); + const body = JSON.parse(response.body) as { readonly value: { readonly displayName: string } }; + assert.equal(body.value.displayName, 'Công ty Ánh Dương'); const resolutions = await app.inject({ method: 'GET', url: `/v1/reference-entities/${entityId}/resolutions`, diff --git a/services/api/test/features/dsm/rule-set.controller.test.ts b/services/api/test/features/dsm/rule-set.controller.test.ts index b0918f65..dba7c87a 100644 --- a/services/api/test/features/dsm/rule-set.controller.test.ts +++ b/services/api/test/features/dsm/rule-set.controller.test.ts @@ -62,7 +62,8 @@ void test('[DSM-008, DSM-010, DSM-021] rule-set publication preserves typed dete payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, }); assert.equal(response.statusCode, 200); - assert.equal(response.json().value.status, 'PUBLISHED'); + const body = JSON.parse(response.body) as { readonly value: { readonly status: string } }; + assert.equal(body.value.status, 'PUBLISHED'); } finally { await app.close(); } diff --git a/services/api/test/features/iae/artifact-admission.controller.test.ts b/services/api/test/features/iae/artifact-admission.controller.test.ts index 197a7954..6dae83c3 100644 --- a/services/api/test/features/iae/artifact-admission.controller.test.ts +++ b/services/api/test/features/iae/artifact-admission.controller.test.ts @@ -59,7 +59,10 @@ void test('IAE-009/010 admission HTTP endpoint persists clean status without sou }, }); assert.equal(response.statusCode, 201); - assert.equal(response.json().value.version.status, 'ACTIVE'); + const body = JSON.parse(response.body) as { + readonly value: { readonly version: { readonly status: string } }; + }; + assert.equal(body.value.version.status, 'ACTIVE'); assert.doesNotMatch(response.body, /sourcePath|rawBytes|excerpt/iu); } finally { await app.close(); diff --git a/services/api/test/features/iae/artifact-lineage.controller.test.ts b/services/api/test/features/iae/artifact-lineage.controller.test.ts index 6219ae49..1cfbd03d 100644 --- a/services/api/test/features/iae/artifact-lineage.controller.test.ts +++ b/services/api/test/features/iae/artifact-lineage.controller.test.ts @@ -53,14 +53,18 @@ void test('[IAE-007] lineage endpoints resolve exact derived and source versions url: `/v1/artifact-versions/${derivedVersionId}/lineage`, }); assert.equal(derived.statusCode, 200); - assert.equal(derived.json().value.derivedArtifactVersionId, derivedVersionId); + const derivedBody = JSON.parse(derived.body) as { + readonly value: { readonly derivedArtifactVersionId: string }; + }; + assert.equal(derivedBody.value.derivedArtifactVersionId, derivedVersionId); const source = await app.inject({ method: 'GET', url: `/v1/artifact-versions/${sourceVersionId}/derived-lineage`, }); assert.equal(source.statusCode, 200); - assert.equal(source.json().value.length, 1); + const sourceBody = JSON.parse(source.body) as { readonly value: readonly unknown[] }; + assert.equal(sourceBody.value.length, 1); } finally { await app.close(); } diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts index 5b0f6904..5850915b 100644 --- a/services/api/test/features/iae/artifact-read.controller.test.ts +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -70,7 +70,13 @@ void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact cont try { const response = await app.inject({ method: 'GET', url: `/v1/artifact-versions/${versionId}` }); assert.equal(response.statusCode, 200); - const body = response.json(); + const body = JSON.parse(response.body) as { + readonly accepted: boolean; + readonly value: { + readonly version: { readonly versionId: string }; + readonly placements: readonly [{ readonly opaqueReference: string }]; + }; + }; assert.equal(body.accepted, true); assert.equal(body.value.version.versionId, versionId); assert.equal(body.value.placements[0].opaqueReference, 'local-placement-000001'); @@ -81,7 +87,10 @@ void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact cont url: `/v1/artifact-versions/${versionId}/evidence`, }); assert.equal(evidenceResponse.statusCode, 200); - assert.deepEqual(evidenceResponse.json().value[0].coordinate, { + const evidenceBody = JSON.parse(evidenceResponse.body) as { + readonly value: readonly [{ readonly coordinate: Record }]; + }; + assert.deepEqual(evidenceBody.value[0].coordinate, { kind: 'ROW', row: 1, field: 'amount', diff --git a/services/api/test/features/iae/prisma-artifact-export-repository.test.ts b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts index a3d71b97..10482b69 100644 --- a/services/api/test/features/iae/prisma-artifact-export-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts @@ -5,7 +5,11 @@ import { createArtifactExportManifestV1 } from '@databreeze/domain/artifact-expo import { parseTenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; -import { PrismaArtifactExportRepositoryAdapter } from '../../../src/features/iae/adapter/prisma-artifact-export-repository.adapter.js'; +import { + PrismaArtifactExportRepositoryAdapter, + type ArtifactExportDatabaseClientV1, + type ArtifactExportDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-export-repository.adapter.js'; const organizationId = '11111111-1111-4111-8111-111111111111'; const workspaceId = '22222222-2222-4222-8222-222222222222'; @@ -43,23 +47,23 @@ const manifest = createArtifactExportManifestV1({ }); if (!manifest.accepted) throw new Error('fixture manifest invalid'); -test('IAE-018 Prisma export adapter preserves immutable manifests and scopes reads', async () => { - const rows = new Map(); - const client = { +void test('IAE-018 Prisma export adapter preserves immutable manifests and scopes reads', async () => { + const rows = new Map(); + const client: ArtifactExportDatabaseClientV1 = { artifactExportManifestRecord: { - async create({ data }: any) { + create({ data }) { const row = { ...data }; rows.set(row.id, row); - return row; + return Promise.resolve(row); }, - async findUnique({ where }: any) { - return rows.get(where.id) ?? null; + findUnique({ where }) { + return Promise.resolve(rows.get(where.id) ?? null); }, }, - async $transaction(work: any) { - return work(this); + $transaction(work) { + return work(client); }, - } as any; + }; const repository = new PrismaArtifactExportRepositoryAdapter(client); await repository.save(context, manifest.value); await repository.save(context, manifest.value); From 356a4ff9f8f3cfb25fa873d50a4b53c5bd1bf7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:35:54 +0700 Subject: [PATCH 33/74] chore(api): refresh generated OpenAPI and migration inventory --- services/api/openapi/v1.json | 2163 +++++++++++++++++- services/api/test/openapi.test.ts | 24 + services/api/test/prisma-foundation.test.mjs | 2 + 3 files changed, 2123 insertions(+), 66 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 69c9e6eb..06401995 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1675,10 +1675,1852 @@ "tags": ["artifacts"] } }, + "/v1/artifact-versions/{versionId}": { + "get": { + "operationId": "ArtifactReadController.get", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read immutable artifact-version metadata and placements", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/evidence": { + "get": { + "operationId": "ArtifactReadController.evidence", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List typed evidence references for one immutable version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/lineage": { + "get": { + "operationId": "ArtifactLineageController.forDerived", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read lineage for an exact derived artifact version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/derived-lineage": { + "get": { + "operationId": "ArtifactLineageController.forSource", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List derived versions that use an exact source version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/placements/{placementId}": { + "patch": { + "operationId": "ContentPlacementController.update", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "placementId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateContentPlacementDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Update verified placement availability with a revision precondition", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/deletion-requests": { + "post": { + "operationId": "ArtifactRetentionController.request", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateArtifactDeletionRequestDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Request governed deletion of an exact artifact version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-deletion-requests/{requestId}/authorize": { + "post": { + "operationId": "ArtifactRetentionController.authorize", + "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthorizeArtifactDeletionRequestDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Authorize an eligible deletion request after MFA step-up", + "tags": ["artifacts"] + } + }, + "/v1/artifacts/exports": { + "post": { + "operationId": "ArtifactExportController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateArtifactExportDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Create an immutable artifact verification manifest", + "tags": ["artifacts"] + } + }, + "/v1/artifacts/exports/{manifestId}": { + "get": { + "operationId": "ArtifactExportController.get", + "parameters": [ + { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read an immutable artifact verification manifest", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions": { + "post": { + "operationId": "ArtifactUploadController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateArtifactUploadSessionDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Create a bounded resumable artifact upload session", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}": { + "get": { + "operationId": "ArtifactUploadController.find", + "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read upload session metadata and completed part digests", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}/parts": { + "post": { + "operationId": "ArtifactUploadController.part", + "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecordArtifactUploadPartDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Record one verified upload part digest", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}/complete": { + "post": { + "operationId": "ArtifactUploadController.complete", + "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CompleteArtifactUploadDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Finalize an upload after all part digests and the assembled hash match", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}/abort": { + "post": { + "operationId": "ArtifactUploadController.abort", + "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AbortArtifactUploadDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Abort an open upload session", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/admit": { + "post": { + "operationId": "ArtifactAdmissionController.admit", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/AdmitArtifactDto" } } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Admit an exact artifact version after digest, media, size, and scan checks", + "tags": ["artifacts"] + } + }, "/v1/datasets": { "post": { - "operationId": "GovernedDatasetController.create", + "operationId": "GovernedDatasetController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Create an immutable governed dataset definition draft", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions": { + "get": { + "operationId": "GovernedDatasetController.list", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List governed dataset versions visible to the caller", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions/{versionId}": { + "get": { + "operationId": "GovernedDatasetController.getVersion", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read one exact immutable governed dataset definition", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions/{versionId}/publish": { + "post": { + "operationId": "GovernedDatasetController.publish", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublishGovernedDatasetDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Publish a governed dataset definition as a new immutable version", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/compatibility": { + "get": { + "operationId": "GovernedDatasetController.compare", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "previousVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "nextVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Classify compatibility between two exact schema versions", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/mappings": { + "post": { + "operationId": "MappingController.create", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Create an immutable mapping definition draft", + "tags": ["datasets"] + }, + "get": { + "operationId": "MappingController.list", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List immutable mapping versions", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/mappings/{versionId}/publish": { + "post": { + "operationId": "MappingController.publish", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Publish a mapping definition as a new immutable version", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/rules": { + "post": { + "operationId": "RuleSetController.create", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Create an immutable quality rule-set draft", + "tags": ["datasets"] + }, + "get": { + "operationId": "RuleSetController.list", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List immutable quality rule-set versions", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/rules/{versionId}/publish": { + "post": { + "operationId": "RuleSetController.publish", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1691,12 +3533,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } + "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -1747,15 +3589,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable governed dataset definition draft", + "summary": "Publish a quality rule set as a new immutable version", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/versions": { - "get": { - "operationId": "GovernedDatasetController.list", + "/v1/reference-entities": { + "post": { + "operationId": "ReferenceEntityController.create", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1764,8 +3605,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -1816,15 +3665,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List governed dataset versions visible to the caller", - "tags": ["datasets"] + "summary": "Create an immutable business-party version", + "tags": ["reference-entities"] } }, - "/v1/datasets/{datasetId}/mappings": { + "/v1/reference-entities/merge": { "post": { - "operationId": "MappingController.create", + "operationId": "ReferenceEntityController.merge", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1836,7 +3684,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } + } } }, "responses": { @@ -1891,13 +3741,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable mapping definition draft", - "tags": ["datasets"] - }, + "summary": "Record an explicit business-party merge resolution", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/{entityId}/versions": { "get": { - "operationId": "MappingController.list", + "operationId": "ReferenceEntityController.list", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1958,15 +3810,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable mapping versions", - "tags": ["datasets"] + "summary": "List immutable business-party versions", + "tags": ["reference-entities"] } }, - "/v1/datasets/{datasetId}/rules": { - "post": { - "operationId": "RuleSetController.create", + "/v1/reference-entities/{entityId}/versions/{versionId}": { + "get": { + "operationId": "ReferenceEntityController.getVersion", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1975,14 +3828,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -2033,13 +3880,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable quality rule-set draft", - "tags": ["datasets"] - }, + "summary": "Read one exact immutable business-party version", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/{entityId}/resolutions": { "get": { - "operationId": "RuleSetController.list", + "operationId": "ReferenceEntityController.resolutions", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2100,13 +3949,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable quality rule-set versions", - "tags": ["datasets"] + "summary": "List immutable merge and resolution history", + "tags": ["reference-entities"] } }, - "/v1/reference-entities": { + "/v1/dataset-versions": { "post": { - "operationId": "ReferenceEntityController.create", + "operationId": "DatasetVersionController.register", "parameters": [ { "name": "X-Correlation-Id", @@ -2120,7 +3969,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } + "schema": { "$ref": "#/components/schemas/RegisterDatasetVersionDto" } } } }, @@ -2176,14 +4025,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable business-party version", - "tags": ["reference-entities"] - } - }, - "/v1/reference-entities/merge": { - "post": { - "operationId": "ReferenceEntityController.merge", + "summary": "Register an immutable dataset result manifest", + "tags": ["datasets"] + }, + "get": { + "operationId": "DatasetVersionController.list", "parameters": [ + { "name": "datasetId", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2192,16 +4040,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -2252,15 +4092,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record an explicit business-party merge resolution", - "tags": ["reference-entities"] + "summary": "List exact dataset result manifests for one governed dataset", + "tags": ["datasets"] } }, - "/v1/reference-entities/{entityId}/versions": { + "/v1/dataset-versions/{versionId}": { "get": { - "operationId": "ReferenceEntityController.list", + "operationId": "DatasetVersionController.get", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2321,8 +4161,8 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable business-party versions", - "tags": ["reference-entities"] + "summary": "Read an exact immutable dataset result manifest", + "tags": ["datasets"] } }, "/v1/devices/sync/operations": { @@ -4056,6 +5896,146 @@ "authorizationEpoch" ] }, + "UpdateContentPlacementDto": { + "type": "object", + "properties": { + "available": { "type": "boolean" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": ["available", "expectedRevision"] + }, + "CreateArtifactDeletionRequestDto": { + "type": "object", + "properties": { + "evaluatedAt": { "type": "string", "format": "date-time" }, + "workspaceRetentionUntil": { "type": "string", "format": "date-time" }, + "resourceRetentionUntil": { "type": "string", "format": "date-time" }, + "auditRetentionUntil": { "type": "string", "format": "date-time" }, + "recoveryWindowUntil": { "type": "string", "format": "date-time" }, + "activeApproval": { "type": "boolean" }, + "legalHold": { "type": "boolean" }, + "requestId": { "type": "string", "format": "uuid" }, + "requestedBy": { "type": "string", "format": "uuid" }, + "requestedAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "evaluatedAt", + "workspaceRetentionUntil", + "resourceRetentionUntil", + "auditRetentionUntil", + "recoveryWindowUntil", + "activeApproval", + "legalHold", + "requestId", + "requestedBy", + "requestedAt" + ] + }, + "AuthorizeArtifactDeletionRequestDto": { + "type": "object", + "properties": { + "evaluatedAt": { "type": "string", "format": "date-time" }, + "workspaceRetentionUntil": { "type": "string", "format": "date-time" }, + "resourceRetentionUntil": { "type": "string", "format": "date-time" }, + "auditRetentionUntil": { "type": "string", "format": "date-time" }, + "recoveryWindowUntil": { "type": "string", "format": "date-time" }, + "activeApproval": { "type": "boolean" }, + "legalHold": { "type": "boolean" }, + "approvedAt": { "type": "string", "format": "date-time" }, + "mfaSatisfied": { "type": "boolean" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": [ + "evaluatedAt", + "workspaceRetentionUntil", + "resourceRetentionUntil", + "auditRetentionUntil", + "recoveryWindowUntil", + "activeApproval", + "legalHold", + "approvedAt", + "mfaSatisfied", + "expectedRevision" + ] + }, + "CreateArtifactExportDto": { + "type": "object", + "properties": { + "manifestId": { "type": "string", "format": "uuid" }, + "versionIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "approvalState": { + "type": "string", + "enum": ["NOT_REQUIRED", "PENDING", "APPROVED", "REJECTED"] + }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": ["manifestId", "versionIds", "approvalState", "createdAt"] + }, + "CreateArtifactUploadSessionDto": { + "type": "object", + "properties": { + "sessionId": { "type": "string", "format": "uuid" }, + "artifactId": { "type": "string", "format": "uuid" }, + "expectedSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "expectedByteSize": { "type": "number", "minimum": 0 }, + "mediaType": { "type": "string" }, + "partSize": { "type": "number", "minimum": 1, "maximum": 1073741824 }, + "createdAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "sessionId", + "artifactId", + "expectedSha256", + "expectedByteSize", + "mediaType", + "partSize", + "createdAt", + "expiresAt" + ] + }, + "RecordArtifactUploadPartDto": { + "type": "object", + "properties": { + "partNumber": { "type": "number", "minimum": 1 }, + "contentSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "byteSize": { "type": "number", "minimum": 0 }, + "uploadedAt": { "type": "string", "format": "date-time" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": ["partNumber", "contentSha256", "byteSize", "uploadedAt", "expectedRevision"] + }, + "CompleteArtifactUploadDto": { + "type": "object", + "properties": { + "assembledSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": ["assembledSha256", "expectedRevision"] + }, + "AbortArtifactUploadDto": { + "type": "object", + "properties": { "expectedRevision": { "type": "number", "minimum": 1 } }, + "required": ["expectedRevision"] + }, + "AdmitArtifactDto": { + "type": "object", + "properties": { + "actualSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "actualByteSize": { "type": "number", "minimum": 0 }, + "detectedMediaType": { "type": "string" }, + "scanState": { "type": "string", "enum": ["PENDING", "CLEAN", "MALICIOUS", "FAILED"] }, + "maxByteSize": { "type": "number", "minimum": 0 }, + "scannedAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "actualSha256", + "actualByteSize", + "detectedMediaType", + "scanState", + "maxByteSize" + ] + }, "GovernedDatasetFieldDto": { "type": "object", "properties": { @@ -4090,6 +6070,14 @@ }, "required": ["datasetId", "versionId", "name", "fields", "createdAt", "canonicalHash"] }, + "PublishGovernedDatasetDto": { + "type": "object", + "properties": { + "nextVersionId": { "type": "string", "format": "uuid" }, + "publishedAt": { "type": "string", "format": "date-time" } + }, + "required": ["nextVersionId", "publishedAt"] + }, "MappingStepDto": { "type": "object", "properties": { @@ -4130,6 +6118,14 @@ "canonicalHash" ] }, + "PublishDefinitionDto": { + "type": "object", + "properties": { + "nextVersionId": { "type": "string", "format": "uuid" }, + "publishedAt": { "type": "string", "format": "date-time" } + }, + "required": ["nextVersionId", "publishedAt"] + }, "CreateRuleSetDto": { "type": "object", "properties": { @@ -4177,6 +6173,41 @@ "resolvedAt" ] }, + "RegisterDatasetVersionDto": { + "type": "object", + "properties": { + "datasetId": { "type": "string", "format": "uuid" }, + "inputArtifactVersionIds": { + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, + "schemaVersionId": { "type": "string", "format": "uuid" }, + "mappingVersionId": { "type": "string", "format": "uuid" }, + "ruleSetVersionId": { "type": "string", "format": "uuid" }, + "engineBuild": { "type": "string", "minLength": 1, "maxLength": 128 }, + "versionId": { "type": "string", "format": "uuid" }, + "contentFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "rowCount": { "type": "number", "minimum": 0 }, + "qualityState": { + "type": "string", + "enum": ["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + }, + "lineageManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": [ + "datasetId", + "inputArtifactVersionIds", + "schemaVersionId", + "mappingVersionId", + "ruleSetVersionId", + "engineBuild", + "versionId", + "contentFingerprint", + "rowCount", + "qualityState", + "lineageManifestHash" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 7dedbec0..3fed31e8 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -63,7 +63,22 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.deepEqual(paths, [ '/health/live', '/health/ready', + '/v1/artifact-deletion-requests/{requestId}/authorize', + '/v1/artifact-upload-sessions', + '/v1/artifact-upload-sessions/{sessionId}', + '/v1/artifact-upload-sessions/{sessionId}/abort', + '/v1/artifact-upload-sessions/{sessionId}/complete', + '/v1/artifact-upload-sessions/{sessionId}/parts', + '/v1/artifact-versions/{versionId}', + '/v1/artifact-versions/{versionId}/admit', + '/v1/artifact-versions/{versionId}/deletion-requests', + '/v1/artifact-versions/{versionId}/derived-lineage', + '/v1/artifact-versions/{versionId}/evidence', + '/v1/artifact-versions/{versionId}/lineage', + '/v1/artifact-versions/{versionId}/placements/{placementId}', '/v1/artifacts/evidence-grants/{grantId}', + '/v1/artifacts/exports', + '/v1/artifacts/exports/{manifestId}', '/v1/artifacts/inbox', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/audit/events', @@ -77,10 +92,17 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-versions', + '/v1/dataset-versions/{versionId}', '/v1/datasets', + '/v1/datasets/{datasetId}/compatibility', '/v1/datasets/{datasetId}/mappings', + '/v1/datasets/{datasetId}/mappings/{versionId}/publish', '/v1/datasets/{datasetId}/rules', + '/v1/datasets/{datasetId}/rules/{versionId}/publish', '/v1/datasets/{datasetId}/versions', + '/v1/datasets/{datasetId}/versions/{versionId}', + '/v1/datasets/{datasetId}/versions/{versionId}/publish', '/v1/devices/enroll', '/v1/devices/enrollment-challenges', '/v1/devices/grants', @@ -103,7 +125,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/organizations/{organizationId}/devices', '/v1/reference-entities', '/v1/reference-entities/merge', + '/v1/reference-entities/{entityId}/resolutions', '/v1/reference-entities/{entityId}/versions', + '/v1/reference-entities/{entityId}/versions/{versionId}', '/v1/system/compatibility', '/v1/system/compatibility/check', ]); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 43e8de30..3afb41d6 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -106,6 +106,8 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802200000_dso_data_mode_policies', '20260802210000_iam_mfa_recovery', '20260802220000_iam_access_tokens', + '20260802230000_iae_retention_exports', + '20260802240000_iae_upload_sessions', 'migration_lock.toml', ]); const migration = await readFile( From 8e430badc9659502cbc00373917ec74ff8ff6b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:39:33 +0700 Subject: [PATCH 34/74] feat(dsm): add immutable dataset quality result contract --- packages/domain/package.json | 4 + packages/domain/src/dataset-quality/v1.ts | 192 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../domain/test/dataset-quality-v1.test.mjs | 91 +++++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 293 insertions(+) create mode 100644 packages/domain/src/dataset-quality/v1.ts create mode 100644 packages/domain/test/dataset-quality-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index ad6b3706..eff432f6 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -92,6 +92,10 @@ "types": "./src/dataset-governance/v1.ts", "import": "./dist/dataset-governance/v1.js" }, + "./dataset-quality/v1": { + "types": "./src/dataset-quality/v1.ts", + "import": "./dist/dataset-quality/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/dataset-quality/v1.ts b/packages/domain/src/dataset-quality/v1.ts new file mode 100644 index 00000000..31d7f855 --- /dev/null +++ b/packages/domain/src/dataset-quality/v1.ts @@ -0,0 +1,192 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { QualityStateV1 } from '../dataset-governance/v1.js'; + +/** DSM-011, DSM-013, DSM-015, DSM-020: immutable, value-free quality evidence. */ +export const DATASET_QUALITY_SCHEMA_VERSION_V1 = 1 as const; + +export type DatasetQualityFindingSeverityV1 = 'INFO' | 'WARNING' | 'ERROR'; + +export interface DatasetQualityFindingV1 { + readonly findingId: StableIdentifierV1; + readonly ruleId: StableIdentifierV1; + readonly severity: DatasetQualityFindingSeverityV1; + readonly messageCode: string; + readonly occurrenceCount: number; + readonly evidenceIds: readonly StableIdentifierV1[]; + readonly detailHash: string; +} + +export interface DatasetQualityResultV1 { + readonly schemaVersion: typeof DATASET_QUALITY_SCHEMA_VERSION_V1; + readonly resultId: StableIdentifierV1; + readonly datasetId: StableIdentifierV1; + readonly datasetVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly ruleSetVersionId: StableIdentifierV1; + readonly profileFingerprint: string; + readonly rowCountScanned: number; + readonly qualityState: QualityStateV1; + readonly findings: readonly DatasetQualityFindingV1[]; + readonly resultFingerprint: string; + readonly createdAt: StrictUtcTimestampV1; +} + +export type DatasetQualityErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_COUNT' + | 'INVALID_TEXT' + | 'INVALID_FINDING' + | 'DUPLICATE_FINDING' + | 'INVALID_QUALITY_STATE'; + +export type DatasetQualityResultV1Of = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: DatasetQualityErrorCodeV1 }; + +function accepted(value: TValue): DatasetQualityResultV1Of { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: DatasetQualityErrorCodeV1): DatasetQualityResultV1Of { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const parsed = parseTenantScopeV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function positiveCount(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; +} + +function finding(input: unknown): DatasetQualityFindingV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const findingId = identifier(record['findingId']); + const ruleId = identifier(record['ruleId']); + const severity = record['severity']; + const messageCode = text(record['messageCode'], 96); + const occurrenceCount = positiveCount(record['occurrenceCount']); + const detailHash = hash(record['detailHash']); + const evidenceInput = record['evidenceIds'] ?? []; + if (!findingId || !ruleId || !messageCode || occurrenceCount === undefined || !detailHash) { + return undefined; + } + if (!['INFO', 'WARNING', 'ERROR'].includes(severity as string)) return undefined; + if (!Array.isArray(evidenceInput) || evidenceInput.length > 128) return undefined; + const evidenceIds = evidenceInput.map(identifier); + if (evidenceIds.some((candidate): candidate is undefined => candidate === undefined)) { + return undefined; + } + return Object.freeze({ + findingId, + ruleId, + severity: severity as DatasetQualityFindingSeverityV1, + messageCode, + occurrenceCount, + evidenceIds: Object.freeze(evidenceIds as StableIdentifierV1[]), + detailHash, + }); +} + +export function qualityStateFromFindingsV1( + findings: readonly DatasetQualityFindingV1[], + incomplete = false, +): QualityStateV1 { + if (incomplete) return 'INCOMPLETE'; + if (findings.some((candidate) => candidate.severity === 'ERROR')) return 'BLOCKED'; + if (findings.some((candidate) => candidate.severity === 'WARNING')) return 'PASS_WITH_WARNINGS'; + return 'PASS'; +} + +export function createDatasetQualityResultV1(input: { + readonly resultId: unknown; + readonly datasetId: unknown; + readonly datasetVersionId: unknown; + readonly tenantScope: unknown; + readonly ruleSetVersionId: unknown; + readonly profileFingerprint: unknown; + readonly rowCountScanned: unknown; + readonly qualityState: unknown; + readonly findings: unknown; + readonly resultFingerprint: unknown; + readonly createdAt: unknown; +}): DatasetQualityResultV1Of { + const resultId = identifier(input.resultId); + const datasetId = identifier(input.datasetId); + const datasetVersionId = identifier(input.datasetVersionId); + const tenantScope = scope(input.tenantScope); + const ruleSetVersionId = identifier(input.ruleSetVersionId); + const profileFingerprint = hash(input.profileFingerprint); + const rowCountScanned = positiveCount(input.rowCountScanned); + const resultFingerprint = hash(input.resultFingerprint); + const createdAt = timestamp(input.createdAt); + if (!resultId || !datasetId || !datasetVersionId || !ruleSetVersionId) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!profileFingerprint || !resultFingerprint) return rejected('INVALID_HASH'); + if (rowCountScanned === undefined) return rejected('INVALID_COUNT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if ( + !['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'].includes(input.qualityState as string) + ) + return rejected('INVALID_QUALITY_STATE'); + if (!Array.isArray(input.findings) || input.findings.length > 512) + return rejected('INVALID_FINDING'); + const findings = input.findings.map(finding); + if (findings.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_FINDING'); + const typedFindings = findings as DatasetQualityFindingV1[]; + if (new Set(typedFindings.map((candidate) => candidate.findingId)).size !== typedFindings.length) + return rejected('DUPLICATE_FINDING'); + return accepted( + Object.freeze({ + schemaVersion: DATASET_QUALITY_SCHEMA_VERSION_V1, + resultId, + datasetId, + datasetVersionId, + tenantScope, + ruleSetVersionId, + profileFingerprint, + rowCountScanned, + qualityState: input.qualityState as QualityStateV1, + findings: Object.freeze(typedFindings), + resultFingerprint, + createdAt, + }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 8826736f..d407116f 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -8,6 +8,7 @@ export * from './artifact-export/v1.js'; export * from './artifact-upload/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; +export * from './dataset-quality/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index f6467ab0..ad533c6f 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -13,6 +13,7 @@ const [ artifactUpload, dataset, datasetGovernance, + datasetQuality, dataMode, jobs, approval, @@ -38,6 +39,7 @@ const [ import('@databreeze/domain/artifact-upload/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), + import('@databreeze/domain/dataset-quality/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -65,6 +67,7 @@ assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); +assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/dataset-quality-v1.test.mjs b/packages/domain/test/dataset-quality-v1.test.mjs new file mode 100644 index 00000000..e83b9293 --- /dev/null +++ b/packages/domain/test/dataset-quality-v1.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createDatasetQualityResultV1, + qualityStateFromFindingsV1, +} from '../dist/dataset-quality/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; +const ids = { + resultId: '00000000-0000-4000-8000-000000000010', + datasetId: '00000000-0000-4000-8000-000000000011', + datasetVersionId: '00000000-0000-4000-8000-000000000012', + ruleSetVersionId: '00000000-0000-4000-8000-000000000013', + findingId: '00000000-0000-4000-8000-000000000014', + ruleId: '00000000-0000-4000-8000-000000000015', + evidenceId: '00000000-0000-4000-8000-000000000016', +}; + +function result(overrides = {}) { + return createDatasetQualityResultV1({ + resultId: ids.resultId, + datasetId: ids.datasetId, + datasetVersionId: ids.datasetVersionId, + tenantScope: scope, + ruleSetVersionId: ids.ruleSetVersionId, + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 42, + qualityState: 'PASS_WITH_WARNINGS', + findings: [ + { + findingId: ids.findingId, + ruleId: ids.ruleId, + severity: 'WARNING', + messageCode: 'NULL_RATE_HIGH', + occurrenceCount: 2, + evidenceIds: [ids.evidenceId], + detailHash: 'b'.repeat(64), + }, + ], + resultFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }); +} + +void test('[DSM-011, DSM-013, DSM-015] quality results are immutable and contain no source values', () => { + const created = result(); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(Object.isFrozen(created.value), true); + assert.equal(Object.isFrozen(created.value.findings[0]), true); + assert.equal('value' in created.value.findings[0], false); + assert.equal(created.value.qualityState, 'PASS_WITH_WARNINGS'); +}); + +void test('[DSM-020] quality state is deterministic from finding severity and completion', () => { + const accepted = result(); + assert.equal(accepted.accepted, true); + if (!accepted.accepted) return; + assert.equal(qualityStateFromFindingsV1(accepted.value.findings), 'PASS_WITH_WARNINGS'); + assert.equal( + qualityStateFromFindingsV1([{ ...accepted.value.findings[0], severity: 'ERROR' }]), + 'BLOCKED', + ); + assert.equal(qualityStateFromFindingsV1([], true), 'INCOMPLETE'); +}); + +void test('[DSM-013] quality result validation rejects malformed hashes, counts, and duplicate findings', () => { + assert.deepEqual(result({ profileFingerprint: 'not-a-hash' }), { + accepted: false, + code: 'INVALID_HASH', + }); + assert.deepEqual(result({ rowCountScanned: -1 }), { + accepted: false, + code: 'INVALID_COUNT', + }); + assert.deepEqual( + result({ + findings: [ + result().accepted ? result().value.findings[0] : undefined, + result().accepted ? result().value.findings[0] : undefined, + ], + }), + { accepted: false, code: 'DUPLICATE_FINDING' }, + ); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index aca1d84d..3ee83500 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -31,6 +31,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-upload/v1', './dataset/v1', './dataset-governance/v1', + './dataset-quality/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -66,6 +67,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DEVICE_AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); From b4e187326150cb5fb0291166c5284d50ee3268b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:42:02 +0700 Subject: [PATCH 35/74] feat(dsm): coordinate tenant-scoped quality results --- ...mory-dataset-quality-repository.adapter.ts | 88 +++++++++++++++++++ .../dataset-quality-repository.port.ts | 24 +++++ .../application/dataset-quality.service.ts | 55 ++++++++++++ .../dsm/dataset-quality.service.test.ts | 78 ++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/dataset-quality-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-quality.service.ts create mode 100644 services/api/test/features/dsm/dataset-quality.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts new file mode 100644 index 00000000..a065247d --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts @@ -0,0 +1,88 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetQualityResultV1 } from '@databreeze/domain/dataset-quality/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetQualityRepositoryPortV1, + DatasetQualityTransactionPortV1, +} from '../application/dataset-quality-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(result: DatasetQualityResultV1): DatasetQualityResultV1 { + return Object.freeze({ + ...result, + tenantScope: Object.freeze({ ...result.tenantScope }), + findings: Object.freeze( + result.findings.map((finding) => + Object.freeze({ ...finding, evidenceIds: Object.freeze([...finding.evidenceIds]) }), + ), + ), + }); +} + +export class InMemoryDatasetQualityRepositoryAdapter implements DatasetQualityRepositoryPortV1 { + private results = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.results.get(result.resultId); + if (existing && JSON.stringify(existing) !== JSON.stringify(result)) + throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); + this.results.set(result.resultId, clone(result)); + } + + public async find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise { + await Promise.resolve(); + const result = this.results.get(resultId); + return result && visible(context.tenantScope, result.tenantScope) ? clone(result) : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + await Promise.resolve(); + return [...this.results.values()] + .filter( + (result) => + result.datasetVersionId === datasetVersionId && + visible(context.tenantScope, result.tenantScope), + ) + .sort((left, right) => left.resultId.localeCompare(right.resultId)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetQualityTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.results); + try { + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); + } catch (error) { + this.results = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/dataset-quality-repository.port.ts b/services/api/src/features/dsm/application/dataset-quality-repository.port.ts new file mode 100644 index 00000000..64aa260c --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-quality-repository.port.ts @@ -0,0 +1,24 @@ +import type { DatasetQualityResultV1 } from '@databreeze/domain/dataset-quality/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_QUALITY_REPOSITORY_PORT = Symbol('DATASET_QUALITY_REPOSITORY_PORT'); + +export interface DatasetQualityTransactionPortV1 { + save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise; + find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise; + list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise; +} + +export interface DatasetQualityRepositoryPortV1 extends DatasetQualityTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetQualityTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-quality.service.ts b/services/api/src/features/dsm/application/dataset-quality.service.ts new file mode 100644 index 00000000..9041d1c9 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-quality.service.ts @@ -0,0 +1,55 @@ +import { + createDatasetQualityResultV1, + type DatasetQualityResultV1, + type DatasetQualityResultV1Of, +} from '@databreeze/domain/dataset-quality/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetQualityRepositoryPortV1 } from './dataset-quality-repository.port.js'; + +export type DatasetQualityServiceErrorV1 = 'QUALITY_RESULT_NOT_FOUND'; +export type DatasetQualityServiceResultV1 = + | DatasetQualityResultV1Of + | { readonly accepted: false; readonly code: DatasetQualityServiceErrorV1 }; + +/** Coordinates immutable, value-free dataset profiling and validation results. */ +export class DatasetQualityService { + public constructor(private readonly repository: DatasetQualityRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createDatasetQualityResultV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.resultId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise> { + const found = await this.repository.find(context, resultId); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'QUALITY_RESULT_NOT_FOUND' as const }); + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetVersionId), + ); + } +} diff --git a/services/api/test/features/dsm/dataset-quality.service.test.ts b/services/api/test/features/dsm/dataset-quality.service.test.ts new file mode 100644 index 00000000..624ce577 --- /dev/null +++ b/services/api/test/features/dsm/dataset-quality.service.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryDatasetQualityRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.js'; +import { DatasetQualityService } from '../../../src/features/dsm/application/dataset-quality.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000003'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; + +function context(workspace: string, idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid identifier'); + return result.value; +} + +const input = { + resultId: '00000000-0000-4000-8000-000000000020', + datasetId: '00000000-0000-4000-8000-000000000021', + datasetVersionId: '00000000-0000-4000-8000-000000000022', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + ruleSetVersionId: '00000000-0000-4000-8000-000000000023', + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 12, + qualityState: 'PASS', + findings: [], + resultFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[DSM-011, DSM-013, DSM-015] service registers and replays immutable quality results', async () => { + const service = new DatasetQualityService(new InMemoryDatasetQualityRepositoryAdapter()); + const created = await service.register(context(workspaceId, 'quality-1'), input); + assert.equal(created.accepted, true); + assert.deepEqual(await service.register(context(workspaceId, 'quality-1'), input), created); + const found = await service.find(context(workspaceId, 'quality-read'), stable(input.resultId)); + assert.deepEqual(found, created); + assert.equal( + (await service.list(context(workspaceId, 'quality-list'), stable(input.datasetVersionId))) + .length, + 1, + ); +}); + +void test('[IAM-009, DSM-018] sibling workspaces cannot read quality results', async () => { + const service = new DatasetQualityService(new InMemoryDatasetQualityRepositoryAdapter()); + await service.register(context(workspaceId, 'quality-2'), { + ...input, + resultId: '00000000-0000-4000-8000-000000000024', + }); + assert.equal( + ( + await service.list( + context(siblingWorkspaceId, 'quality-sibling'), + stable(input.datasetVersionId), + ) + ).length, + 0, + ); +}); From 53acdea23de681d6a4a67360f13d356681bb1263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:45:04 +0700 Subject: [PATCH 36/74] feat(dsm): persist quality results with Prisma --- .../migration.sql | 23 +++ services/api/prisma/schema/dsm.prisma | 23 +++ ...isma-dataset-quality-repository.adapter.ts | 190 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 20 ++ .../prisma-dataset-quality-repository.test.ts | 95 +++++++++ services/api/test/prisma-foundation.test.mjs | 12 ++ 6 files changed, 363 insertions(+) create mode 100644 services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts diff --git a/services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql b/services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql new file mode 100644 index 00000000..4694bc0a --- /dev/null +++ b/services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "dsm"."dataset_quality_results" ( + "id" UUID NOT NULL, + "dataset_id" UUID NOT NULL, + "dataset_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "rule_set_version_id" UUID NOT NULL, + "profile_fingerprint" CHAR(64) NOT NULL, + "row_count_scanned" BIGINT NOT NULL, + "quality_state" VARCHAR(24) NOT NULL, + "findings" JSONB NOT NULL, + "result_fingerprint" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dataset_quality_results_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "dataset_quality_results_dataset_version_idx" + ON "dsm"."dataset_quality_results"("dataset_version_id"); +CREATE INDEX "dataset_quality_results_scope_idx" + ON "dsm"."dataset_quality_results"("organization_id", "workspace_id", "project_id", "dataset_version_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index b635d0d9..749b55d6 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -49,6 +49,29 @@ model DatasetVersionRecord { @@schema("dsm") } +/// DSM-011, DSM-013, DSM-015, DSM-020: immutable value-free profiling and validation results. +model DatasetQualityResultRecord { + id String @id @db.Uuid + datasetId String @map("dataset_id") @db.Uuid + datasetVersionId String @map("dataset_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + ruleSetVersionId String @map("rule_set_version_id") @db.Uuid + profileFingerprint String @map("profile_fingerprint") @db.Char(64) + rowCountScanned BigInt @map("row_count_scanned") + qualityState String @map("quality_state") @db.VarChar(24) + findings Json + resultFingerprint String @map("result_fingerprint") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([datasetVersionId], map: "dataset_quality_results_dataset_version_idx") + @@index([organizationId, workspaceId, projectId, datasetVersionId], map: "dataset_quality_results_scope_idx") + @@map("dataset_quality_results") + @@schema("dsm") +} + /// DSM-025: canonical workspace reference identities are versioned and immutable. model ReferenceEntityVersionRecord { id String @id @db.Uuid diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts new file mode 100644 index 00000000..b3becaec --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts @@ -0,0 +1,190 @@ +import { + createDatasetQualityResultV1, + type DatasetQualityResultV1, +} from '@databreeze/domain/dataset-quality/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetQualityRepositoryPortV1, + DatasetQualityTransactionPortV1, +} from '../application/dataset-quality-repository.port.js'; + +export interface DatasetQualityDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly datasetVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly ruleSetVersionId: string; + readonly profileFingerprint: string; + readonly rowCountScanned: bigint | number; + readonly qualityState: string; + readonly findings: unknown; + readonly resultFingerprint: string; + readonly createdAt: Date; +} + +export interface DatasetQualityDatabaseCreateDataV1 + extends Omit { + readonly rowCountScanned: bigint; + readonly createdAt: Date; +} + +export interface DatasetQualityDatabaseClientV1 { + readonly datasetQualityResultRecord: { + create(input: { + readonly data: DatasetQualityDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetQualityDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: DatasetQualityDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: DatasetQualityDatabaseRowV1): DatasetQualityResultV1 { + const parsed = createDatasetQualityResultV1({ + resultId: row.id, + datasetId: row.datasetId, + datasetVersionId: row.datasetVersionId, + tenantScope: rowScope(row), + ruleSetVersionId: row.ruleSetVersionId, + profileFingerprint: row.profileFingerprint, + rowCountScanned: + typeof row.rowCountScanned === 'bigint' ? Number(row.rowCountScanned) : row.rowCountScanned, + qualityState: row.qualityState, + findings: row.findings, + resultFingerprint: row.resultFingerprint, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_QUALITY_RESULT_INVALID'); + return parsed.value; +} + +function domainToCreate(result: DatasetQualityResultV1): DatasetQualityDatabaseCreateDataV1 { + return { + ...databaseScope(result.tenantScope), + id: result.resultId, + datasetId: result.datasetId, + datasetVersionId: result.datasetVersionId, + ruleSetVersionId: result.ruleSetVersionId, + profileFingerprint: result.profileFingerprint, + rowCountScanned: BigInt(result.rowCountScanned), + qualityState: result.qualityState, + findings: result.findings, + resultFingerprint: result.resultFingerprint, + createdAt: new Date(result.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: DatasetQualityDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetQualityTransactionAdapter implements DatasetQualityTransactionPortV1 { + public constructor(private readonly client: DatasetQualityDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetQualityResultRecord.findUnique({ + where: { id: result.resultId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(result)) + throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); + return; + } + await this.client.datasetQualityResultRecord.create({ data: domainToCreate(result) }); + } + + public async find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise { + const row = await this.client.datasetQualityResultRecord.findUnique({ + where: { id: resultId }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + const rows = await this.client.datasetQualityResultRecord.findMany({ + where: { datasetVersionId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaDatasetQualityRepositoryAdapter implements DatasetQualityRepositoryPortV1 { + public constructor(private readonly client: DatasetQualityDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetQualityTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetQualityTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise { + return new PrismaDatasetQualityTransactionAdapter(this.client).save(context, result); + } + + public find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise { + return new PrismaDatasetQualityTransactionAdapter(this.client).find(context, resultId); + } + + public list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + return new PrismaDatasetQualityTransactionAdapter(this.client).list(context, datasetVersionId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 903e4325..c0b0e198 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -26,6 +26,11 @@ import { PrismaDatasetVersionRepositoryAdapter, type DatasetVersionDatabaseClientV1, } from './adapter/prisma-dataset-version-repository.adapter.js'; +import { InMemoryDatasetQualityRepositoryAdapter } from './adapter/in-memory-dataset-quality-repository.adapter.js'; +import { + PrismaDatasetQualityRepositoryAdapter, + type DatasetQualityDatabaseClientV1, +} from './adapter/prisma-dataset-quality-repository.adapter.js'; import { PrismaRuleSetRepositoryAdapter, type RuleSetDatabaseClientV1, @@ -50,6 +55,10 @@ import { DATASET_VERSION_REPOSITORY_PORT, type DatasetVersionRepositoryPortV1, } from './application/dataset-version-repository.port.js'; +import { + DATASET_QUALITY_REPOSITORY_PORT, + type DatasetQualityRepositoryPortV1, +} from './application/dataset-quality-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -72,6 +81,9 @@ export interface DsmModuleOptions { readonly datasetVersionRepository?: DatasetVersionRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetVersionDatabase?: DatasetVersionDatabaseClientV1; + readonly datasetQualityRepository?: DatasetQualityRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetQualityDatabase?: DatasetQualityDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -128,6 +140,14 @@ export class DsmModule { ? new InMemoryDatasetVersionRepositoryAdapter() : new PrismaDatasetVersionRepositoryAdapter(options.datasetVersionDatabase)), }, + { + provide: DATASET_QUALITY_REPOSITORY_PORT, + useValue: + options.datasetQualityRepository ?? + (options.datasetQualityDatabase === undefined + ? new InMemoryDatasetQualityRepositoryAdapter() + : new PrismaDatasetQualityRepositoryAdapter(options.datasetQualityDatabase)), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts new file mode 100644 index 00000000..00c82ead --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts @@ -0,0 +1,95 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { createDatasetQualityResultV1 } from '@databreeze/domain/dataset-quality/v1'; +import { + PrismaDatasetQualityRepositoryAdapter, + type DatasetQualityDatabaseClientV1, + type DatasetQualityDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000901'); +const workspaceId = id('00000000-0000-4000-8000-000000000902'); +const resultId = id('00000000-0000-4000-8000-000000000903'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000904', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000905', + idempotencyKey: 'prisma-quality-result', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: DatasetQualityDatabaseRowV1[]): DatasetQualityDatabaseClientV1 { + return { + datasetQualityResultRecord: { + create({ data }) { + const persisted = { ...data } as DatasetQualityDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.datasetVersionId === where['datasetVersionId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-011, DSM-013, IAM-009] Prisma quality adapter persists immutable scoped results', async () => { + const tenantContext = context(); + const created = createDatasetQualityResultV1({ + resultId, + datasetId: '00000000-0000-4000-8000-000000000906', + datasetVersionId: '00000000-0000-4000-8000-000000000907', + tenantScope: tenantContext.tenantScope, + ruleSetVersionId: '00000000-0000-4000-8000-000000000908', + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 5, + qualityState: 'PASS', + findings: [], + resultFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const rows: DatasetQualityDatabaseRowV1[] = []; + const repository = new PrismaDatasetQualityRepositoryAdapter(client(rows)); + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, resultId), created.value); + assert.deepEqual(await repository.list(tenantContext, created.value.datasetVersionId), [ + created.value, + ]); + assert.equal(rows.length, 1); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 3afb41d6..b44e46a7 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -61,6 +61,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "bua"\."usage_ledger_entries"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_definitions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -108,6 +109,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802220000_iam_access_tokens', '20260802230000_iae_retention_exports', '20260802240000_iae_upload_sessions', + '20260802250000_dsm_quality_results', 'migration_lock.toml', ]); const migration = await readFile( @@ -400,4 +402,14 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const qualityMigration = await readFile( + path.join(migrationsDirectory, inventory[26], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "dsm"."dataset_quality_results"', + 'CREATE INDEX "dataset_quality_results_dataset_version_idx"', + ]) { + assert.match(qualityMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } }); From 9c121179f101109259a7718b853c6b8b4def3d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:48:31 +0700 Subject: [PATCH 37/74] feat(dsm): expose dataset quality result API --- services/api/openapi/v1.json | 271 ++++++++++++++++++ .../dsm/api/dataset-quality.controller.ts | 63 ++++ .../features/dsm/api/dataset-quality.dto.ts | 103 +++++++ services/api/src/features/dsm/dsm.module.ts | 2 + .../dsm/dataset-quality.controller.test.ts | 108 +++++++ services/api/test/openapi.test.ts | 2 + 6 files changed, 549 insertions(+) create mode 100644 services/api/src/features/dsm/api/dataset-quality.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-quality.dto.ts create mode 100644 services/api/test/features/dsm/dataset-quality.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 06401995..b3a25913 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -4165,6 +4165,223 @@ "tags": ["datasets"] } }, + "/v1/dataset-quality-results": { + "post": { + "operationId": "DatasetQualityController.register", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterDatasetQualityResultDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Register an immutable, value-free dataset quality result", + "tags": ["datasets"] + }, + "get": { + "operationId": "DatasetQualityController.list", + "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List quality results for one exact dataset version", + "tags": ["datasets"] + } + }, + "/v1/dataset-quality-results/{resultId}": { + "get": { + "operationId": "DatasetQualityController.get", + "parameters": [ + { "name": "resultId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read an exact immutable dataset quality result", + "tags": ["datasets"] + } + }, "/v1/devices/sync/operations": { "post": { "operationId": "DeviceSyncController.enqueue", @@ -6208,6 +6425,60 @@ "lineageManifestHash" ] }, + "DatasetQualityFindingDto": { + "type": "object", + "properties": { + "findingId": { "type": "string", "format": "uuid" }, + "ruleId": { "type": "string", "format": "uuid" }, + "severity": { "type": "string", "enum": ["INFO", "WARNING", "ERROR"] }, + "messageCode": { "type": "string", "minLength": 1, "maxLength": 96 }, + "occurrenceCount": { "type": "number", "minimum": 0 }, + "evidenceIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "detailHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": [ + "findingId", + "ruleId", + "severity", + "messageCode", + "occurrenceCount", + "evidenceIds", + "detailHash" + ] + }, + "RegisterDatasetQualityResultDto": { + "type": "object", + "properties": { + "resultId": { "type": "string", "format": "uuid" }, + "datasetId": { "type": "string", "format": "uuid" }, + "datasetVersionId": { "type": "string", "format": "uuid" }, + "ruleSetVersionId": { "type": "string", "format": "uuid" }, + "profileFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "rowCountScanned": { "type": "number", "minimum": 0 }, + "qualityState": { + "type": "string", + "enum": ["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + }, + "findings": { + "type": "array", + "items": { "$ref": "#/components/schemas/DatasetQualityFindingDto" } + }, + "resultFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "resultId", + "datasetId", + "datasetVersionId", + "ruleSetVersionId", + "profileFingerprint", + "rowCountScanned", + "qualityState", + "findings", + "resultFingerprint", + "createdAt" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/src/features/dsm/api/dataset-quality.controller.ts b/services/api/src/features/dsm/api/dataset-quality.controller.ts new file mode 100644 index 00000000..6fb2db58 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-quality.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + DATASET_QUALITY_REPOSITORY_PORT, + type DatasetQualityRepositoryPortV1, +} from '../application/dataset-quality-repository.port.js'; +import { DatasetQualityService } from '../application/dataset-quality.service.js'; +import { RegisterDatasetQualityResultDto } from './dataset-quality.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-quality-results') +export class DatasetQualityController { + private readonly quality: DatasetQualityService; + + public constructor( + @Inject(DATASET_QUALITY_REPOSITORY_PORT) repository: DatasetQualityRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.quality = new DatasetQualityService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable, value-free dataset quality result' }) + @ApiBody({ type: RegisterDatasetQualityResultDto }) + async register( + @Req() request: unknown, + @Body() input: RegisterDatasetQualityResultDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.quality.register(context, { + ...input, + tenantScope: context.tenantScope, + }); + } + + @Get(':resultId') + @ApiOperation({ summary: 'Read an exact immutable dataset quality result' }) + async get(@Req() request: unknown, @Param('resultId') resultIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const resultId = parseStableIdentifierV1(resultIdInput); + if (!resultId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.quality.find(context, resultId.value); + } + + @Get() + @ApiOperation({ summary: 'List quality results for one exact dataset version' }) + async list( + @Req() request: unknown, + @Query('datasetVersionId') datasetVersionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetVersionId = parseStableIdentifierV1(datasetVersionIdInput); + if (!datasetVersionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.quality.list(context, datasetVersionId.value); + } +} diff --git a/services/api/src/features/dsm/api/dataset-quality.dto.ts b/services/api/src/features/dsm/api/dataset-quality.dto.ts new file mode 100644 index 00000000..1863646d --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-quality.dto.ts @@ -0,0 +1,103 @@ +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class DatasetQualityFindingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + findingId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleId!: string; + + @ApiProperty({ enum: ['INFO', 'WARNING', 'ERROR'] }) + @IsIn(['INFO', 'WARNING', 'ERROR']) + severity!: 'INFO' | 'WARNING' | 'ERROR'; + + @ApiProperty({ minLength: 1, maxLength: 96 }) + @IsString() + @MinLength(1) + @MaxLength(96) + messageCode!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + occurrenceCount!: number; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayMaxSize(128) + @IsUUID('4', { each: true }) + evidenceIds!: string[]; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + detailHash!: string; +} + +export class RegisterDatasetQualityResultDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + resultId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleSetVersionId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + profileFingerprint!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCountScanned!: number; + + @ApiProperty({ enum: ['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'] }) + @IsIn(['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE']) + qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + + @ApiProperty({ type: [DatasetQualityFindingDto] }) + @IsArray() + @ArrayMaxSize(512) + @ValidateNested({ each: true }) + @Type(() => DatasetQualityFindingDto) + findings!: DatasetQualityFindingDto[]; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + resultFingerprint!: string; + + @ApiProperty({ format: 'date-time' }) + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index c0b0e198..00f4d99d 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -5,6 +5,7 @@ import { MappingController } from './api/mapping.controller.js'; import { ReferenceEntityController } from './api/reference-entity.controller.js'; import { RuleSetController } from './api/rule-set.controller.js'; import { DatasetVersionController } from './api/dataset-version.controller.js'; +import { DatasetQualityController } from './api/dataset-quality.controller.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -98,6 +99,7 @@ export class DsmModule { RuleSetController, ReferenceEntityController, DatasetVersionController, + DatasetQualityController, ], providers: [ { diff --git a/services/api/test/features/dsm/dataset-quality.controller.test.ts b/services/api/test/features/dsm/dataset-quality.controller.test.ts new file mode 100644 index 00000000..9966b1a1 --- /dev/null +++ b/services/api/test/features/dsm/dataset-quality.controller.test.ts @@ -0,0 +1,108 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetQualityRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000921'; +const workspaceId = '00000000-0000-4000-8000-000000000922'; +const resultId = '00000000-0000-4000-8000-000000000923'; +const datasetVersionId = '00000000-0000-4000-8000-000000000924'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000925', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000926', + idempotencyKey: 'quality-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-011, DSM-013, DSM-015] quality HTTP surfaces never accept source values', async () => { + const tenantContext = context(); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetQualityRepository: new InMemoryDatasetQualityRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-quality-results', + payload: { + resultId, + datasetId: '00000000-0000-4000-8000-000000000927', + datasetVersionId, + ruleSetVersionId: '00000000-0000-4000-8000-000000000928', + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 42, + qualityState: 'PASS_WITH_WARNINGS', + findings: [ + { + findingId: '00000000-0000-4000-8000-000000000929', + ruleId: '00000000-0000-4000-8000-000000000930', + severity: 'WARNING', + messageCode: 'NULL_RATE_HIGH', + occurrenceCount: 3, + evidenceIds: [], + detailHash: 'b'.repeat(64), + }, + ], + resultFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(response.statusCode, 201); + assert.equal(response.body.includes('sourceValue'), false); + const read = await app.inject({ + method: 'GET', + url: `/v1/dataset-quality-results/${resultId}`, + }); + assert.equal(read.statusCode, 200); + const listed = await app.inject({ + method: 'GET', + url: `/v1/dataset-quality-results?datasetVersionId=${datasetVersionId}`, + }); + assert.equal(listed.statusCode, 200); + assert.equal(JSON.parse(listed.body).length, 1); + } finally { + await app.close(); + } +}); + +void test('[DSM-013] quality DTO rejects unsupported source-bearing fields and malformed fingerprints', async () => { + const { app } = await createApiApplication({ + datasetQualityRepository: new InMemoryDatasetQualityRepositoryAdapter(), + requestTenantContext: { resolve: () => Promise.resolve(context()) }, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-quality-results', + payload: { + resultId, + datasetId: '00000000-0000-4000-8000-000000000927', + datasetVersionId, + ruleSetVersionId: '00000000-0000-4000-8000-000000000928', + profileFingerprint: 'not-a-hash', + rowCountScanned: 0, + qualityState: 'PASS', + findings: [], + resultFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + sourceValue: 'must-not-be-accepted', + }, + }); + assert.equal(response.statusCode, 400); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 3fed31e8..99c7c34b 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -92,6 +92,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-quality-results', + '/v1/dataset-quality-results/{resultId}', '/v1/dataset-versions', '/v1/dataset-versions/{versionId}', '/v1/datasets', From e87d729ea993399465e887d6c028803fcb27d5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:50:13 +0700 Subject: [PATCH 38/74] feat(engine): evaluate value-free dataset quality rules --- .../processors/dataset_quality.py | 122 ++++++++++++++++++ services/engine/tests/test_dataset_quality.py | 55 ++++++++ 2 files changed, 177 insertions(+) create mode 100644 services/engine/src/databreeze_engine/processors/dataset_quality.py create mode 100644 services/engine/tests/test_dataset_quality.py diff --git a/services/engine/src/databreeze_engine/processors/dataset_quality.py b/services/engine/src/databreeze_engine/processors/dataset_quality.py new file mode 100644 index 00000000..42708e21 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/dataset_quality.py @@ -0,0 +1,122 @@ +"""Deterministic, value-free dataset quality evaluation (DSM-013, DSM-015, DSM-020).""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr + +from .dataset_profile import DatasetProfile + +QualitySeverity = Literal["INFO", "WARNING", "ERROR"] +QualityState = Literal["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + + +class QualityFinding(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + ruleId: StrictStr = Field(pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + severity: QualitySeverity + messageCode: StrictStr = Field(pattern=r"^[A-Z][A-Z0-9_.-]{0,95}$") + occurrenceCount: StrictInt = Field(ge=0) + detailHash: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +class DatasetQualityEvaluation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + profileFingerprint: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + rowCountScanned: StrictInt = Field(ge=0) + qualityState: QualityState + findings: tuple[QualityFinding, ...] + resultFingerprint: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +def _digest(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def profile_fingerprint(profile: DatasetProfile) -> str: + """Return a stable digest of profile metadata, never source values.""" + return _digest(profile.model_dump(mode="json")) + + +def _required_count(profile: DatasetProfile, field: str) -> int | None: + for summary in profile.fields: + if summary.field == field: + return ( + summary.stateCounts["MISSING"] + + summary.stateCounts["NULL"] + + summary.stateCounts["BLANK"] + ) + return None + + +def evaluate_required_fields( + profile: DatasetProfile, + required_rules: Sequence[Mapping[str, object]], +) -> DatasetQualityEvaluation: + """Evaluate bounded REQUIRED rules from a profile without receiving row values.""" + findings: list[QualityFinding] = [] + for rule in required_rules: + rule_id = rule.get("ruleId") + field = rule.get("field") + severity = rule.get("severity", "ERROR") + if not isinstance(rule_id, str) or not isinstance(field, str): + raise ValueError("required rules need a ruleId and field") + if severity not in {"ERROR", "WARNING"}: + raise ValueError("required rule severity is invalid") + missing_count = _required_count(profile, field) + occurrence_count = profile.sourceRowCount if missing_count is None else missing_count + message_code = "FIELD_NOT_PROFILED" if missing_count is None else "REQUIRED_VALUE_MISSING" + finding_digest = _digest( + { + "ruleId": rule_id, + "field": field, + "occurrenceCount": occurrence_count, + "messageCode": message_code, + } + ) + if occurrence_count > 0 or missing_count is None: + findings.append( + QualityFinding( + ruleId=rule_id, + severity=severity, + messageCode=message_code, + occurrenceCount=occurrence_count, + detailHash=finding_digest, + ) + ) + quality_state: QualityState + if any(finding.severity == "ERROR" for finding in findings): + quality_state = "BLOCKED" + elif any(finding.severity == "WARNING" for finding in findings): + quality_state = "PASS_WITH_WARNINGS" + else: + quality_state = "PASS" + profile_digest = profile_fingerprint(profile) + result_digest = _digest( + { + "profileFingerprint": profile_digest, + "rowCountScanned": profile.rowCountScanned, + "qualityState": quality_state, + "findings": [finding.model_dump(mode="json") for finding in findings], + } + ) + return DatasetQualityEvaluation( + profileFingerprint=profile_digest, + rowCountScanned=profile.rowCountScanned, + qualityState=quality_state, + findings=tuple(findings), + resultFingerprint=result_digest, + ) diff --git a/services/engine/tests/test_dataset_quality.py b/services/engine/tests/test_dataset_quality.py new file mode 100644 index 00000000..30b454ec --- /dev/null +++ b/services/engine/tests/test_dataset_quality.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from databreeze_engine.processors.dataset_profile import profile_records +from databreeze_engine.processors.dataset_quality import ( + evaluate_required_fields, + profile_fingerprint, +) + + +def test_required_quality_is_deterministic_and_value_free() -> None: + profile = profile_records( + [{"amount": 0}, {"amount": None}, {"amount": 2}], + ["amount"], + ) + result = evaluate_required_fields( + profile, + [ + { + "ruleId": "00000000-0000-4000-8000-000000000001", + "field": "amount", + "severity": "WARNING", + } + ], + ) + assert result.qualityState == "PASS_WITH_WARNINGS" + assert result.findings[0].occurrenceCount == 1 + assert "amount" not in result.findings[0].detailHash + assert profile_fingerprint(profile) == result.profileFingerprint + + +def test_missing_profiled_field_is_disclosed_and_error_blocks() -> None: + profile = profile_records([{"code": "A"}], ["code"]) + result = evaluate_required_fields( + profile, + [ + { + "ruleId": "00000000-0000-4000-8000-000000000002", + "field": "amount", + "severity": "ERROR", + } + ], + ) + assert result.qualityState == "BLOCKED" + assert result.findings[0].messageCode == "FIELD_NOT_PROFILED" + assert result.findings[0].occurrenceCount == 1 + + +def test_invalid_rule_shape_fails_closed() -> None: + profile = profile_records([{"code": "A"}], ["code"]) + try: + evaluate_required_fields(profile, [{"field": "code"}]) + except ValueError as error: + assert str(error) == "required rules need a ruleId and field" + else: + raise AssertionError("invalid rule should fail") From 6b3c8cd99671b0a0a3c2018cd56d95aec9678153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:51:58 +0700 Subject: [PATCH 39/74] feat(iae): add revisioned inbox metadata policy --- packages/domain/src/artifact-intake/v1.ts | 75 ++++++++++++++++++- .../domain/test/artifact-intake-v1.test.mjs | 36 +++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/artifact-intake/v1.ts b/packages/domain/src/artifact-intake/v1.ts index 86318807..ef57f1e9 100644 --- a/packages/domain/src/artifact-intake/v1.ts +++ b/packages/domain/src/artifact-intake/v1.ts @@ -21,6 +21,7 @@ export type InboxItemStateV1 = | 'QUARANTINED' | 'ARCHIVED'; export type ArtifactScanStateV1 = 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; +export type InboxPriorityV1 = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'; export interface InboxItemV1 { readonly schemaVersion: typeof ARTIFACT_INTAKE_SCHEMA_VERSION_V1; @@ -31,6 +32,10 @@ export interface InboxItemV1 { readonly state: InboxItemStateV1; readonly createdAt: StrictUtcTimestampV1; readonly revision: number; + readonly assigneeId?: StableIdentifierV1; + readonly labels?: readonly string[]; + readonly priority?: InboxPriorityV1; + readonly dueAt?: StrictUtcTimestampV1; } export type ArtifactIntakeErrorCodeV1 = @@ -47,7 +52,9 @@ export type ArtifactIntakeErrorCodeV1 = | 'SIZE_MISMATCH' | 'MEDIA_MISMATCH' | 'SIZE_POLICY_EXCEEDED' - | 'SCAN_NOT_COMPLETE'; + | 'SCAN_NOT_COMPLETE' + | 'INVALID_METADATA' + | 'REVISION_CONFLICT'; export type ArtifactIntakeResultV1 = | { readonly accepted: true; readonly value: TValue } @@ -146,6 +153,72 @@ export function transitionInboxItemV1( return accepted(Object.freeze({ ...item, state: nextState, revision: item.revision + 1 })); } +/** IAE-013: metadata updates are revisioned and never change artifact identity or state. */ +export function updateInboxMetadataV1( + item: InboxItemV1, + input: { + readonly assigneeId?: unknown; + readonly labels?: unknown; + readonly priority?: unknown; + readonly dueAt?: unknown; + readonly expectedRevision: unknown; + }, +): ArtifactIntakeResultV1 { + if ( + typeof input.expectedRevision !== 'number' || + !Number.isSafeInteger(input.expectedRevision) || + input.expectedRevision < 1 + ) + return rejected('INVALID_METADATA'); + if (input.expectedRevision !== item.revision) return rejected('REVISION_CONFLICT'); + let assigneeId = item.assigneeId; + if (input.assigneeId !== undefined) { + if (input.assigneeId === null) assigneeId = undefined; + else { + assigneeId = identifier(input.assigneeId); + if (!assigneeId) return rejected('INVALID_METADATA'); + } + } + let labels = item.labels; + if (input.labels !== undefined) { + if (!Array.isArray(input.labels) || input.labels.length > 32) + return rejected('INVALID_METADATA'); + const parsedLabels = input.labels.map((label) => text(label, 64)); + if ( + parsedLabels.some((label): label is undefined => label === undefined) || + new Set(parsedLabels).size !== parsedLabels.length + ) + return rejected('INVALID_METADATA'); + labels = Object.freeze(parsedLabels as string[]); + } + let priority = item.priority; + if (input.priority !== undefined) { + if (!['LOW', 'NORMAL', 'HIGH', 'URGENT'].includes(input.priority as string)) + return rejected('INVALID_METADATA'); + priority = input.priority as InboxPriorityV1; + } + let dueAt = item.dueAt; + if (input.dueAt !== undefined) { + if (input.dueAt === null) dueAt = undefined; + else { + dueAt = timestamp(input.dueAt); + if (!dueAt) return rejected('INVALID_METADATA'); + } + } + const next = { ...item, revision: item.revision + 1 }; + if (input.assigneeId !== undefined) { + if (assigneeId === undefined) delete next.assigneeId; + else next.assigneeId = assigneeId; + } + if (labels !== undefined) next.labels = labels; + if (priority !== undefined) next.priority = priority; + if (input.dueAt !== undefined) { + if (dueAt === undefined) delete next.dueAt; + else next.dueAt = dueAt; + } + return accepted(Object.freeze(next)); +} + export function finalizeArtifactAdmissionV1(input: { readonly artifact: ArtifactVersionV1; readonly actualSha256: unknown; diff --git a/packages/domain/test/artifact-intake-v1.test.mjs b/packages/domain/test/artifact-intake-v1.test.mjs index 0ff97b2f..d705ed72 100644 --- a/packages/domain/test/artifact-intake-v1.test.mjs +++ b/packages/domain/test/artifact-intake-v1.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import { createInboxItemV1, finalizeArtifactAdmissionV1, + updateInboxMetadataV1, transitionInboxItemV1, } from '../dist/artifact-intake/v1.js'; import { createArtifactVersionV1 } from '../dist/artifact/v1.js'; @@ -84,3 +85,38 @@ void test('[IAE-009, IAE-010] admission requires digest, size, media signature, { accepted: true, value: { status: 'QUARANTINED', scanState: 'MALICIOUS' } }, ); }); + +void test('[IAE-013] inbox metadata is bounded, revisioned, and clearable', () => { + const created = createInboxItemV1({ + inboxItemId: '00000000-0000-4000-8000-000000000030', + tenantScope: scope, + idempotencyKey: 'metadata-1', + artifactVersionId: baseArtifact.versionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const updated = updateInboxMetadataV1(created.value, { + assigneeId: '00000000-0000-4000-8000-000000000031', + labels: ['finance', 'urgent'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + expectedRevision: 1, + }); + assert.equal(updated.accepted, true); + if (!updated.accepted) return; + assert.equal(updated.value.priority, 'HIGH'); + assert.equal(updated.value.revision, 2); + const cleared = updateInboxMetadataV1(updated.value, { + assigneeId: null, + labels: [], + dueAt: null, + expectedRevision: 2, + }); + assert.equal(cleared.accepted, true); + if (cleared.accepted) assert.equal('assigneeId' in cleared.value, false); + assert.deepEqual( + updateInboxMetadataV1(created.value, { priority: 'INVALID', expectedRevision: 1 }), + { accepted: false, code: 'INVALID_METADATA' }, + ); +}); From 76e7053f5558a0a92b0a1bae756ea40b51f94ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:54:36 +0700 Subject: [PATCH 40/74] feat(iae): coordinate inbox metadata updates --- ...mory-artifact-intake-repository.adapter.ts | 23 ++++++- .../application/artifact-intake.service.ts | 19 ++++++ .../artifact-intake-metadata.service.test.ts | 61 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 services/api/test/features/iae/artifact-intake-metadata.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts index 30e48555..0b0f3932 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts @@ -1,4 +1,9 @@ -import { tenantScopeContainsV1, type InboxItemV1, type TenantScopeV1 } from '@databreeze/domain/v1'; +import { + tenantScopeContainsV1, + updateInboxMetadataV1, + type InboxItemV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { @@ -39,6 +44,22 @@ export class InMemoryArtifactIntakeRepositoryAdapter implements ArtifactIntakeRe item.revision !== existing.revision + 1 ) throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); + if (existing.state === item.state) { + const metadataInput = { + ...(Object.hasOwn(item, 'assigneeId') || Object.hasOwn(existing, 'assigneeId') + ? { assigneeId: Object.hasOwn(item, 'assigneeId') ? item.assigneeId : null } + : {}), + ...(Object.hasOwn(item, 'labels') ? { labels: item.labels } : {}), + ...(Object.hasOwn(item, 'priority') ? { priority: item.priority } : {}), + ...(Object.hasOwn(item, 'dueAt') || Object.hasOwn(existing, 'dueAt') + ? { dueAt: Object.hasOwn(item, 'dueAt') ? item.dueAt : null } + : {}), + expectedRevision: existing.revision, + }; + const metadata = updateInboxMetadataV1(existing, metadataInput); + if (!metadata.accepted || JSON.stringify(metadata.value) !== JSON.stringify(item)) + throw new Error('IAE_INVALID_INBOX_METADATA'); + } } const sameKey = [...this.items.values()].find( (candidate) => diff --git a/services/api/src/features/iae/application/artifact-intake.service.ts b/services/api/src/features/iae/application/artifact-intake.service.ts index 69545c82..2a84c174 100644 --- a/services/api/src/features/iae/application/artifact-intake.service.ts +++ b/services/api/src/features/iae/application/artifact-intake.service.ts @@ -2,6 +2,7 @@ import { createInboxItemV1, finalizeArtifactAdmissionV1, transitionInboxItemV1, + updateInboxMetadataV1, type ArtifactIntakeResultV1, type ArtifactScanStateV1, type InboxItemV1, @@ -79,4 +80,22 @@ export class ArtifactIntakeService { public async list(context: IamTenantContextV1): Promise { return this.repository.withTransaction(context, (transaction) => transaction.list(context)); } + + public async updateMetadata( + context: IamTenantContextV1, + inboxItemId: InboxItemV1['inboxItemId'], + input: Omit[1], 'expectedRevision'>, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const item = await transaction.find(context, inboxItemId); + if (!item) return Object.freeze({ accepted: false, code: 'INBOX_NOT_FOUND' as const }); + const updated = updateInboxMetadataV1(item, { + ...input, + expectedRevision: context.expectedRevision ?? item.revision, + }); + if (!updated.accepted) return updated; + await transaction.save(context, updated.value); + return updated; + }); + } } diff --git a/services/api/test/features/iae/artifact-intake-metadata.service.test.ts b/services/api/test/features/iae/artifact-intake-metadata.service.test.ts new file mode 100644 index 00000000..be5e9ede --- /dev/null +++ b/services/api/test/features/iae/artifact-intake-metadata.service.test.ts @@ -0,0 +1,61 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactIntakeRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.js'; +import { ArtifactIntakeService } from '../../../src/features/iae/application/artifact-intake.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +const organizationId = '00000000-0000-4000-8000-000000000631'; +const workspaceId = '00000000-0000-4000-8000-000000000632'; +const inboxItemId = '00000000-0000-4000-8000-000000000633'; +const artifactVersionId = '00000000-0000-4000-8000-000000000634'; + +function context(idempotencyKey: string, expectedRevision?: number) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000635', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000636', + idempotencyKey, + ...(expectedRevision === undefined ? {} : { expectedRevision }), + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture identifier rejected'); + return result.value; +} + +void test('[IAE-013] service updates assignment, labels, priority, and due date with optimistic revisions', async () => { + const service = new ArtifactIntakeService(new InMemoryArtifactIntakeRepositoryAdapter()); + const created = await service.create(context('metadata-create'), { + inboxItemId, + tenantScope: context('metadata-scope').tenantScope, + idempotencyKey: 'metadata-item', + artifactVersionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + const updated = await service.updateMetadata(context('metadata-update', 1), stable(inboxItemId), { + assigneeId: '00000000-0000-4000-8000-000000000637', + labels: ['finance'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + }); + assert.equal(updated.accepted, true); + if (updated.accepted) { + assert.equal(updated.value.priority, 'HIGH'); + assert.deepEqual(updated.value.labels, ['finance']); + assert.equal(updated.value.revision, 2); + } + const stale = await service.updateMetadata(context('metadata-stale', 1), stable(inboxItemId), { + labels: ['stale'], + }); + assert.deepEqual(stale, { accepted: false, code: 'REVISION_CONFLICT' }); +}); From e6207c2bc044dc9c31d9ffe163145bca5efa099a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:02:20 +0700 Subject: [PATCH 41/74] feat(iae): persist inbox metadata with Prisma --- .../migration.sql | 5 ++ services/api/prisma/schema/iae.prisma | 4 + ...isma-artifact-intake-repository.adapter.ts | 88 +++++++++++++++++-- services/api/test/prisma-foundation.test.mjs | 17 ++++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql diff --git a/services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql b/services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql new file mode 100644 index 00000000..a246f82d --- /dev/null +++ b/services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "iae"."inbox_items" + ADD COLUMN "assignee_id" UUID, + ADD COLUMN "labels" JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN "priority" VARCHAR(16) NOT NULL DEFAULT 'NORMAL', + ADD COLUMN "due_at" TIMESTAMPTZ(6); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 4ed5343f..0733a70d 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -34,6 +34,10 @@ model InboxItem { idempotencyKey String @map("idempotency_key") @db.VarChar(200) artifactVersionId String @map("artifact_version_id") @db.Uuid state String @db.VarChar(24) + assigneeId String? @map("assignee_id") @db.Uuid + labels Json @default("[]") + priority String @default("NORMAL") @db.VarChar(16) + dueAt DateTime? @map("due_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) revision Int @default(1) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts index 4f201512..67aabd58 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts @@ -1,6 +1,7 @@ import { createInboxItemV1, transitionInboxItemV1, + updateInboxMetadataV1, type InboxItemStateV1, type InboxItemV1, } from '@databreeze/domain/artifact-intake/v1'; @@ -26,6 +27,10 @@ export interface ArtifactIntakeDatabaseRowV1 { readonly idempotencyKey: string; readonly artifactVersionId: string; readonly state: string; + readonly assigneeId?: string | null; + readonly labels?: unknown; + readonly priority?: string; + readonly dueAt?: Date | null; readonly createdAt: Date; readonly revision: number; } @@ -39,6 +44,10 @@ export interface ArtifactIntakeDatabaseCreateDataV1 { readonly idempotencyKey: string; readonly artifactVersionId: string; readonly state: InboxItemStateV1; + readonly assigneeId?: string | null; + readonly labels?: unknown; + readonly priority?: string; + readonly dueAt?: Date | null; readonly createdAt: Date; readonly revision: number; } @@ -59,7 +68,14 @@ export interface ArtifactIntakeDatabaseDelegateV1 { }): Promise; update(input: { readonly where: { readonly id: string }; - readonly data: { readonly state: InboxItemStateV1; readonly revision: number }; + readonly data: { + readonly state: InboxItemStateV1; + readonly revision: number; + readonly assigneeId: string | null; + readonly labels: unknown; + readonly priority: string; + readonly dueAt: Date | null; + }; }): Promise; } @@ -120,8 +136,16 @@ function rowToDomain(row: ArtifactIntakeDatabaseRowV1): InboxItemV1 { if (!Number.isSafeInteger(row.revision) || row.revision < 1) { throw new Error('IAE_PERSISTED_REVISION_INVALID'); } + const metadata = updateInboxMetadataV1(created.value, { + ...(row.assigneeId === undefined ? {} : { assigneeId: row.assigneeId }), + ...(row.labels === undefined ? {} : { labels: row.labels }), + ...(row.priority === undefined ? {} : { priority: row.priority }), + ...(row.dueAt === undefined || row.dueAt === null ? {} : { dueAt: row.dueAt.toISOString() }), + expectedRevision: 1, + }); + if (!metadata.accepted) throw new Error('IAE_PERSISTED_METADATA_INVALID'); return Object.freeze({ - ...created.value, + ...metadata.value, state: row.state as InboxItemStateV1, revision: row.revision, }); @@ -135,11 +159,38 @@ function domainToCreate(item: InboxItemV1): ArtifactIntakeDatabaseCreateDataV1 { idempotencyKey: item.idempotencyKey, artifactVersionId: item.artifactVersionId, state: item.state, + assigneeId: item.assigneeId ?? null, + labels: item.labels ?? [], + priority: item.priority ?? 'NORMAL', + dueAt: item.dueAt === undefined ? null : new Date(item.dueAt), createdAt: new Date(item.createdAt), revision: item.revision, }; } +/** + * Prisma applies defaults for metadata columns while older callers may omit + * those optional fields. Compare the persisted representation semantically so + * a replay of the same immutable item remains idempotent and state transitions + * do not fail merely because the database materialized defaults. + */ +function comparable(item: InboxItemV1): string { + return JSON.stringify({ + schemaVersion: item.schemaVersion, + inboxItemId: item.inboxItemId, + tenantScope: item.tenantScope, + idempotencyKey: item.idempotencyKey, + artifactVersionId: item.artifactVersionId, + state: item.state, + createdAt: item.createdAt, + revision: item.revision, + assigneeId: item.assigneeId ?? null, + labels: item.labels ?? [], + priority: item.priority ?? 'NORMAL', + dueAt: item.dueAt ?? null, + }); +} + function visible(context: TenantScopeV1, row: ArtifactIntakeDatabaseRowV1): boolean { const candidate = domainScope(row); return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); @@ -164,7 +215,7 @@ class PrismaArtifactIntakeTransactionAdapter implements ArtifactIntakeTransactio const existing = await this.client.inboxItem.findUnique({ where: { id: item.inboxItemId } }); if (existing !== null) { const current = rowToDomain(existing); - if (JSON.stringify(current) === JSON.stringify(item)) return; + if (comparable(current) === comparable(item)) return; if (context.expectedRevision !== current.revision) { throw new Error('IAE_REVISION_CONFLICT'); } @@ -176,13 +227,36 @@ class PrismaArtifactIntakeTransactionAdapter implements ArtifactIntakeTransactio ) { throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); } - const transition = transitionInboxItemV1(current, item.state); - if (!transition.accepted || JSON.stringify(transition.value) !== JSON.stringify(item)) { - throw new Error('IAE_INVALID_INBOX_TRANSITION'); + if (current.state === item.state) { + const metadata = updateInboxMetadataV1(current, { + ...(Object.hasOwn(item, 'assigneeId') || Object.hasOwn(current, 'assigneeId') + ? { assigneeId: Object.hasOwn(item, 'assigneeId') ? item.assigneeId : null } + : {}), + ...(Object.hasOwn(item, 'labels') ? { labels: item.labels } : {}), + ...(Object.hasOwn(item, 'priority') ? { priority: item.priority } : {}), + ...(Object.hasOwn(item, 'dueAt') || Object.hasOwn(current, 'dueAt') + ? { dueAt: Object.hasOwn(item, 'dueAt') ? item.dueAt : null } + : {}), + expectedRevision: current.revision, + }); + if (!metadata.accepted || comparable(metadata.value) !== comparable(item)) + throw new Error('IAE_INVALID_INBOX_METADATA'); + } else { + const transition = transitionInboxItemV1(current, item.state); + if (!transition.accepted || comparable(transition.value) !== comparable(item)) { + throw new Error('IAE_INVALID_INBOX_TRANSITION'); + } } await this.client.inboxItem.update({ where: { id: item.inboxItemId }, - data: { state: item.state, revision: item.revision }, + data: { + state: item.state, + revision: item.revision, + assigneeId: item.assigneeId ?? null, + labels: item.labels ?? [], + priority: item.priority ?? 'NORMAL', + dueAt: item.dueAt === undefined ? null : new Date(item.dueAt), + }, }); return; } diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index b44e46a7..fc83978a 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -55,6 +55,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "iam"\."users"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_versions"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."inbox_items"/); + assert.match(diff.stdout, /"assignee_id" UUID/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_lineage"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."evidence_grants"/); assert.match(diff.stdout, /CREATE TABLE "aud"\."audit_events"/); @@ -110,6 +111,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802230000_iae_retention_exports', '20260802240000_iae_upload_sessions', '20260802250000_dsm_quality_results', + '20260802260000_iae_inbox_metadata', 'migration_lock.toml', ]); const migration = await readFile( @@ -412,4 +414,19 @@ test('the schema diff and centrally ordered migration inventory establish platfo ]) { assert.match(qualityMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } + const inboxMetadataMigration = await readFile( + path.join(migrationsDirectory, inventory[27], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'ADD COLUMN "assignee_id" UUID', + 'ADD COLUMN "labels" JSONB', + 'ADD COLUMN "priority" VARCHAR(16)', + 'ADD COLUMN "due_at" TIMESTAMPTZ(6)', + ]) { + assert.match( + inboxMetadataMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 0aba003cfc736ef44e9d2bd0d2d57a6b897093f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:07:59 +0700 Subject: [PATCH 42/74] feat(iae): expose revisioned inbox metadata patch --- services/api/openapi/v1.json | 94 +++++++++++++++++++ .../src/features/iae/api/inbox-item.dto.ts | 50 +++++++++- .../src/features/iae/api/inbox.controller.ts | 49 +++++++++- .../features/iae/inbox.controller.test.ts | 54 +++++++++++ services/api/test/openapi.test.ts | 1 + 5 files changed, 244 insertions(+), 4 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index b3a25913..9a832413 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1528,6 +1528,90 @@ "tags": ["artifacts"] } }, + "/v1/artifacts/inbox/{inboxItemId}": { + "patch": { + "operationId": "InboxController.updateMetadata", + "parameters": [ + { "name": "inboxItemId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "If-Match", + "in": "header", + "description": "Expected inbox revision, for example 3 or \"3\".", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateInboxMetadataDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Update revisioned, content-free inbox triage metadata", + "tags": ["artifacts"] + } + }, "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { "post": { "operationId": "EvidenceGrantController.issue", @@ -6093,6 +6177,16 @@ }, "required": ["inboxItemId", "artifactVersionId", "createdAt"] }, + "UpdateInboxMetadataDto": { + "type": "object", + "properties": { + "assigneeId": { "type": "string", "format": "uuid", "nullable": true }, + "labels": { "maxItems": 32, "type": "array", "items": { "type": "string" } }, + "priority": { "type": "string", "enum": ["LOW", "NORMAL", "HIGH", "URGENT"] }, + "dueAt": { "type": "string", "format": "date-time", "nullable": true }, + "expectedRevision": { "type": "number", "minimum": 1 } + } + }, "CreateEvidenceGrantDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iae/api/inbox-item.dto.ts b/services/api/src/features/iae/api/inbox-item.dto.ts index c00246fd..bcec3d00 100644 --- a/services/api/src/features/iae/api/inbox-item.dto.ts +++ b/services/api/src/features/iae/api/inbox-item.dto.ts @@ -1,5 +1,20 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsISO8601, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsISO8601, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +import type { InboxPriorityV1 } from '@databreeze/domain/artifact-intake/v1'; /** IAE-001: content-free, idempotent intake registration request. */ export class CreateInboxItemDto { @@ -20,3 +35,36 @@ export class CreateInboxItemDto { @MinLength(1) idempotencyKey?: string; } + +/** IAE-013: revisioned, content-free inbox triage metadata patch. */ +export class UpdateInboxMetadataDto { + @ApiProperty({ type: String, format: 'uuid', nullable: true, required: false }) + @IsOptional() + @IsUUID() + assigneeId?: string | null; + + @ApiProperty({ type: [String], maxItems: 32, required: false }) + @IsOptional() + @IsArray() + @ArrayMaxSize(32) + @ArrayUnique() + @IsString({ each: true }) + @MaxLength(64, { each: true }) + labels?: string[]; + + @ApiProperty({ enum: ['LOW', 'NORMAL', 'HIGH', 'URGENT'], required: false }) + @IsOptional() + @IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT']) + priority?: InboxPriorityV1; + + @ApiProperty({ type: String, format: 'date-time', nullable: true, required: false }) + @IsOptional() + @IsISO8601() + dueAt?: string | null; + + @ApiProperty({ minimum: 1, required: false }) + @IsOptional() + @IsInt() + @Min(1) + expectedRevision?: number; +} diff --git a/services/api/src/features/iae/api/inbox.controller.ts b/services/api/src/features/iae/api/inbox.controller.ts index 0eb7f45f..5f627ecb 100644 --- a/services/api/src/features/iae/api/inbox.controller.ts +++ b/services/api/src/features/iae/api/inbox.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Headers, Inject, Post, Req } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { ARTIFACT_INTAKE_REPOSITORY_PORT, @@ -9,12 +10,20 @@ import { ArtifactIntakeService, type ArtifactIntakeServiceResultV1, } from '../application/artifact-intake.service.js'; -import { CreateInboxItemDto } from './inbox-item.dto.js'; +import { CreateInboxItemDto, UpdateInboxMetadataDto } from './inbox-item.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; +function parseRevisionHeader(value: string | undefined): number | 'INVALID' | undefined { + if (value === undefined) return undefined; + const match = /^(?:W\/)?"?([1-9][0-9]*)"?$/u.exec(value.trim()); + if (!match) return 'INVALID'; + const revision = Number(match[1]); + return Number.isSafeInteger(revision) ? revision : 'INVALID'; +} + @ApiTags('artifacts') @ApiBearerAuth() @Controller('v1/artifacts') @@ -54,4 +63,38 @@ export class InboxController { const context = await this.requestContext.resolve(request); return this.intake.list(context); } + + @Patch('inbox/:inboxItemId') + @ApiOperation({ summary: 'Update revisioned, content-free inbox triage metadata' }) + @ApiHeader({ + name: 'If-Match', + required: false, + description: 'Expected inbox revision, for example 3 or "3".', + }) + @ApiBody({ type: UpdateInboxMetadataDto }) + async updateMetadata( + @Req() request: unknown, + @Headers('if-match') ifMatch: string | undefined, + @Body() input: UpdateInboxMetadataDto, + @Param('inboxItemId') inboxItemId: string, + ): Promise> { + const context = await this.requestContext.resolve(request); + const headerRevision = parseRevisionHeader(ifMatch); + if (headerRevision === 'INVALID') + return Object.freeze({ accepted: false, code: 'INVALID_METADATA' as const }); + const expectedRevision = input.expectedRevision ?? headerRevision ?? context.expectedRevision; + const parsedId = parseStableIdentifierV1(inboxItemId); + if (!parsedId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + if (expectedRevision === undefined) + return Object.freeze({ accepted: false, code: 'INVALID_METADATA' as const }); + const mutationContext = + expectedRevision === undefined ? context : Object.freeze({ ...context, expectedRevision }); + return this.intake.updateMetadata(mutationContext, parsedId.value, { + ...(Object.hasOwn(input, 'assigneeId') ? { assigneeId: input.assigneeId } : {}), + ...(Object.hasOwn(input, 'labels') ? { labels: input.labels } : {}), + ...(Object.hasOwn(input, 'priority') ? { priority: input.priority } : {}), + ...(Object.hasOwn(input, 'dueAt') ? { dueAt: input.dueAt } : {}), + }); + } } diff --git a/services/api/test/features/iae/inbox.controller.test.ts b/services/api/test/features/iae/inbox.controller.test.ts index 14ead4f9..fbc4f7e8 100644 --- a/services/api/test/features/iae/inbox.controller.test.ts +++ b/services/api/test/features/iae/inbox.controller.test.ts @@ -54,3 +54,57 @@ void test('[IAE-001, IAM-009] HTTP inbox listing uses the configured tenant cont await app.close(); } }); + +void test('[IAE-013] HTTP inbox metadata patch uses a revision precondition and stays content-free', async () => { + const repository = new InMemoryArtifactIntakeRepositoryAdapter(); + const tenantContext = context(); + const intake = new ArtifactIntakeService(repository); + const created = await intake.create(tenantContext, { + inboxItemId, + tenantScope: tenantContext.tenantScope, + idempotencyKey: 'http-inbox-metadata', + artifactVersionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactIntakeRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'PATCH', + url: `/v1/artifacts/inbox/${inboxItemId}`, + headers: { 'if-match': '1' }, + payload: { + labels: ['finance', 'urgent'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + path: 'must-not-be-accepted', + }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, /must-not-be-accepted/u); + + const accepted = await app.inject({ + method: 'PATCH', + url: `/v1/artifacts/inbox/${inboxItemId}`, + headers: { 'if-match': '1' }, + payload: { + labels: ['finance', 'urgent'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + }, + }); + assert.equal(accepted.statusCode, 200); + const body: unknown = JSON.parse(accepted.body); + assert.ok(typeof body === 'object' && body !== null && 'accepted' in body); + assert.equal((body as { readonly accepted: boolean }).accepted, true); + assert.doesNotMatch(accepted.body, /path|source|byte|excerpt/u); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 99c7c34b..c497d07f 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -80,6 +80,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/exports', '/v1/artifacts/exports/{manifestId}', '/v1/artifacts/inbox', + '/v1/artifacts/inbox/{inboxItemId}', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/audit/events', '/v1/audit/seals', From ecd89c97dbc59fd1006b3a71fad1b527894e72ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:07:59 +0700 Subject: [PATCH 43/74] fix(test): remove unsafe quality response assertion --- .../api/test/features/dsm/dataset-quality.controller.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/dsm/dataset-quality.controller.test.ts b/services/api/test/features/dsm/dataset-quality.controller.test.ts index 9966b1a1..81b8f47e 100644 --- a/services/api/test/features/dsm/dataset-quality.controller.test.ts +++ b/services/api/test/features/dsm/dataset-quality.controller.test.ts @@ -72,7 +72,9 @@ void test('[DSM-011, DSM-013, DSM-015] quality HTTP surfaces never accept source url: `/v1/dataset-quality-results?datasetVersionId=${datasetVersionId}`, }); assert.equal(listed.statusCode, 200); - assert.equal(JSON.parse(listed.body).length, 1); + const listedBody: unknown = JSON.parse(listed.body); + assert.ok(Array.isArray(listedBody)); + assert.equal(listedBody.length, 1); } finally { await app.close(); } From 511c4837483b600e298f92b4abcfce280af797de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:10:57 +0700 Subject: [PATCH 44/74] feat(dsm): add reproducible profile disclosure contract --- packages/domain/package.json | 4 + packages/domain/src/dataset-profile/v1.ts | 173 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../domain/test/dataset-profile-v1.test.mjs | 59 ++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 242 insertions(+) create mode 100644 packages/domain/src/dataset-profile/v1.ts create mode 100644 packages/domain/test/dataset-profile-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index eff432f6..87bbba70 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -96,6 +96,10 @@ "types": "./src/dataset-quality/v1.ts", "import": "./dist/dataset-quality/v1.js" }, + "./dataset-profile/v1": { + "types": "./src/dataset-profile/v1.ts", + "import": "./dist/dataset-profile/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/dataset-profile/v1.ts b/packages/domain/src/dataset-profile/v1.ts new file mode 100644 index 00000000..2bf20713 --- /dev/null +++ b/packages/domain/src/dataset-profile/v1.ts @@ -0,0 +1,173 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-011: bounded, reproducible profiling disclosure without source values. */ +export const DATASET_PROFILE_SCHEMA_VERSION_V1 = 1 as const; + +export type DatasetProfileCompletenessV1 = 'COMPLETE' | 'DETERMINISTIC_SAMPLE'; + +export interface DatasetProfileResourceLimitsV1 { + readonly maxRows: number; + readonly maxBytes: number; + readonly maxDurationMs: number; +} + +export interface DatasetProfileV1 { + readonly schemaVersion: typeof DATASET_PROFILE_SCHEMA_VERSION_V1; + readonly profileId: StableIdentifierV1; + readonly datasetVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly completeness: DatasetProfileCompletenessV1; + readonly samplingMethod: string; + readonly samplingSeed?: string; + readonly excludedScopes: readonly string[]; + readonly rowCountScanned: number; + readonly rowCountAvailable?: number; + readonly resourceLimits: DatasetProfileResourceLimitsV1; + readonly profileFingerprint: string; + readonly createdAt: StrictUtcTimestampV1; +} + +export type DatasetProfileErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_TEXT' + | 'INVALID_HASH' + | 'INVALID_COUNT' + | 'INVALID_COMPLETENESS' + | 'INVALID_SAMPLING' + | 'INVALID_LIMITS'; + +export type DatasetProfileResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: DatasetProfileErrorCodeV1 }; + +function accepted(value: TValue): DatasetProfileResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: DatasetProfileErrorCodeV1): DatasetProfileResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const parsed = parseTenantScopeV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function count(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; +} + +function limit(input: unknown, maximum: number): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 && input <= maximum + ? input + : undefined; +} + +export function createDatasetProfileV1(input: { + readonly profileId: unknown; + readonly datasetVersionId: unknown; + readonly tenantScope: unknown; + readonly completeness: unknown; + readonly samplingMethod: unknown; + readonly samplingSeed?: unknown; + readonly excludedScopes?: unknown; + readonly rowCountScanned: unknown; + readonly rowCountAvailable?: unknown; + readonly resourceLimits: unknown; + readonly profileFingerprint: unknown; + readonly createdAt: unknown; +}): DatasetProfileResultV1 { + const profileId = identifier(input.profileId); + const datasetVersionId = identifier(input.datasetVersionId); + const tenantScope = scope(input.tenantScope); + const completeness = input.completeness; + const samplingMethod = text(input.samplingMethod, 96); + const samplingSeed = input.samplingSeed === undefined ? undefined : hash(input.samplingSeed); + const excludedInput = input.excludedScopes ?? []; + const excludedScopes = Array.isArray(excludedInput) + ? excludedInput.map((value) => text(value, 128)) + : undefined; + const rowCountScanned = count(input.rowCountScanned); + const rowCountAvailable = + input.rowCountAvailable === undefined ? undefined : count(input.rowCountAvailable); + const limits = input.resourceLimits; + const profileFingerprint = hash(input.profileFingerprint); + const createdAt = timestamp(input.createdAt); + + if (!profileId || !datasetVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!['COMPLETE', 'DETERMINISTIC_SAMPLE'].includes(completeness as string)) + return rejected('INVALID_COMPLETENESS'); + if (!samplingMethod) return rejected('INVALID_SAMPLING'); + if (completeness === 'DETERMINISTIC_SAMPLE' && !samplingSeed) return rejected('INVALID_SAMPLING'); + if (completeness === 'COMPLETE' && input.samplingSeed !== undefined) + return rejected('INVALID_SAMPLING'); + if ( + !excludedScopes || + excludedScopes.length > 64 || + excludedScopes.some((value): value is undefined => value === undefined) + ) + return rejected('INVALID_TEXT'); + if (new Set(excludedScopes).size !== excludedScopes.length) return rejected('INVALID_TEXT'); + if (rowCountScanned === undefined) return rejected('INVALID_COUNT'); + if (rowCountAvailable !== undefined && rowCountScanned > rowCountAvailable) + return rejected('INVALID_COUNT'); + if (typeof limits !== 'object' || limits === null || Array.isArray(limits)) + return rejected('INVALID_LIMITS'); + const limitRecord = limits as Record; + const maxRows = limit(limitRecord['maxRows'], 10_000_000); + const maxBytes = limit(limitRecord['maxBytes'], 1024 * 1024 * 1024 * 1024); + const maxDurationMs = limit(limitRecord['maxDurationMs'], 86_400_000); + if (!maxRows || !maxBytes || !maxDurationMs) return rejected('INVALID_LIMITS'); + if (!profileFingerprint) return rejected('INVALID_HASH'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + schemaVersion: DATASET_PROFILE_SCHEMA_VERSION_V1, + profileId, + datasetVersionId, + tenantScope, + completeness: completeness as DatasetProfileCompletenessV1, + samplingMethod, + ...(samplingSeed === undefined ? {} : { samplingSeed }), + excludedScopes: Object.freeze(excludedScopes as string[]), + rowCountScanned, + ...(rowCountAvailable === undefined ? {} : { rowCountAvailable }), + resourceLimits: Object.freeze({ maxRows, maxBytes, maxDurationMs }), + profileFingerprint, + createdAt, + }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index d407116f..26dd8ab6 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -9,6 +9,7 @@ export * from './artifact-upload/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; +export * from './dataset-profile/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index ad533c6f..85c1613c 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -14,6 +14,7 @@ const [ dataset, datasetGovernance, datasetQuality, + datasetProfile, dataMode, jobs, approval, @@ -40,6 +41,7 @@ const [ import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), + import('@databreeze/domain/dataset-profile/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -68,6 +70,7 @@ assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); +assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/dataset-profile-v1.test.mjs b/packages/domain/test/dataset-profile-v1.test.mjs new file mode 100644 index 00000000..c5729e61 --- /dev/null +++ b/packages/domain/test/dataset-profile-v1.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createDatasetProfileV1, + DATASET_PROFILE_SCHEMA_VERSION_V1, +} from '../dist/dataset-profile/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000701', + workspaceId: '00000000-0000-4000-8000-000000000702', +}; + +const base = { + profileId: '00000000-0000-4000-8000-000000000703', + datasetVersionId: '00000000-0000-4000-8000-000000000704', + tenantScope: scope, + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 500, + rowCountAvailable: 1000, + resourceLimits: { maxRows: 10000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[DSM-011] profile disclosure preserves deterministic sampling, exclusions, counts, and limits', () => { + const result = createDatasetProfileV1(base); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.schemaVersion, DATASET_PROFILE_SCHEMA_VERSION_V1); + assert.equal(result.value.completeness, 'DETERMINISTIC_SAMPLE'); + assert.equal(result.value.samplingSeed, 'a'.repeat(64)); + assert.equal(result.value.rowCountScanned, 500); + assert.deepEqual(result.value.resourceLimits, { + maxRows: 10000, + maxBytes: 1000000, + maxDurationMs: 60000, + }); +}); + +void test('[DSM-011] complete profiles reject sample-only fields and impossible counts', () => { + assert.deepEqual( + createDatasetProfileV1({ + ...base, + completeness: 'COMPLETE', + samplingSeed: undefined, + rowCountScanned: 1001, + }), + { accepted: false, code: 'INVALID_COUNT' }, + ); + assert.deepEqual( + createDatasetProfileV1({ ...base, completeness: 'COMPLETE', samplingSeed: 'a'.repeat(64) }), + { accepted: false, code: 'INVALID_SAMPLING' }, + ); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 3ee83500..01dab94f 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -32,6 +32,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './dataset/v1', './dataset-governance/v1', './dataset-quality/v1', + './dataset-profile/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -68,6 +69,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); From 612c1fe673b9b04ca64e35ca5c88083614b229af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:14:56 +0700 Subject: [PATCH 45/74] feat(dsm): coordinate scoped profile disclosures --- ...mory-dataset-profile-repository.adapter.ts | 87 +++++++++++++++++++ .../dataset-profile-repository.port.ts | 24 +++++ .../application/dataset-profile.service.ts | 55 ++++++++++++ .../dsm/dataset-profile.service.test.ts | 67 ++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/dataset-profile-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-profile.service.ts create mode 100644 services/api/test/features/dsm/dataset-profile.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts new file mode 100644 index 00000000..4d479968 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts @@ -0,0 +1,87 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetProfileV1 } from '@databreeze/domain/dataset-profile/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetProfileRepositoryPortV1, + DatasetProfileTransactionPortV1, +} from '../application/dataset-profile-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(profile: DatasetProfileV1): DatasetProfileV1 { + return Object.freeze({ + ...profile, + tenantScope: Object.freeze({ ...profile.tenantScope }), + excludedScopes: Object.freeze([...profile.excludedScopes]), + resourceLimits: Object.freeze({ ...profile.resourceLimits }), + }); +} + +export class InMemoryDatasetProfileRepositoryAdapter implements DatasetProfileRepositoryPortV1 { + private profiles = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.profiles.get(profile.profileId); + if (existing && JSON.stringify(existing) !== JSON.stringify(profile)) + throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); + this.profiles.set(profile.profileId, clone(profile)); + } + + public async find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise { + await Promise.resolve(); + const profile = this.profiles.get(profileId); + return profile && visible(context.tenantScope, profile.tenantScope) + ? clone(profile) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + await Promise.resolve(); + return [...this.profiles.values()] + .filter( + (profile) => + profile.datasetVersionId === datasetVersionId && + visible(context.tenantScope, profile.tenantScope), + ) + .sort((left, right) => left.profileId.localeCompare(right.profileId)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetProfileTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.profiles); + try { + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); + } catch (error) { + this.profiles = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/dataset-profile-repository.port.ts b/services/api/src/features/dsm/application/dataset-profile-repository.port.ts new file mode 100644 index 00000000..9b14d040 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-profile-repository.port.ts @@ -0,0 +1,24 @@ +import type { DatasetProfileV1 } from '@databreeze/domain/dataset-profile/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_PROFILE_REPOSITORY_PORT = Symbol('DATASET_PROFILE_REPOSITORY_PORT'); + +export interface DatasetProfileTransactionPortV1 { + save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise; + find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise; + list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise; +} + +export interface DatasetProfileRepositoryPortV1 extends DatasetProfileTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetProfileTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-profile.service.ts b/services/api/src/features/dsm/application/dataset-profile.service.ts new file mode 100644 index 00000000..baee7f3a --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-profile.service.ts @@ -0,0 +1,55 @@ +import { + createDatasetProfileV1, + type DatasetProfileResultV1, + type DatasetProfileV1, +} from '@databreeze/domain/dataset-profile/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetProfileRepositoryPortV1 } from './dataset-profile-repository.port.js'; + +export type DatasetProfileServiceErrorV1 = 'PROFILE_NOT_FOUND'; +export type DatasetProfileServiceResultV1 = + | DatasetProfileResultV1 + | { readonly accepted: false; readonly code: DatasetProfileServiceErrorV1 }; + +/** Coordinates immutable, value-free profiling disclosure records. */ +export class DatasetProfileService { + public constructor(private readonly repository: DatasetProfileRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createDatasetProfileV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.profileId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise> { + const found = await this.repository.find(context, profileId); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'PROFILE_NOT_FOUND' as const }); + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetVersionId), + ); + } +} diff --git a/services/api/test/features/dsm/dataset-profile.service.test.ts b/services/api/test/features/dsm/dataset-profile.service.test.ts new file mode 100644 index 00000000..93223760 --- /dev/null +++ b/services/api/test/features/dsm/dataset-profile.service.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.js'; +import { DatasetProfileService } from '../../../src/features/dsm/application/dataset-profile.service.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000741', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000742', + workspaceId: '00000000-0000-4000-8000-000000000743', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000744', + idempotencyKey: 'profile-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const input = { + profileId: '00000000-0000-4000-8000-000000000745', + datasetVersionId: '00000000-0000-4000-8000-000000000746', + tenantScope: context.tenantScope, + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 100, + rowCountAvailable: 1000, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[DSM-011, IAM-009] profile service registers immutable scoped disclosures', async () => { + const service = new DatasetProfileService(new InMemoryDatasetProfileRepositoryAdapter()); + const first = await service.register(context, input); + assert.equal(first.accepted, true); + const replay = await service.register(context, input); + assert.equal(replay.accepted, true); + if (!first.accepted || !replay.accepted) return; + assert.deepEqual(replay.value, first.value); + const listed = await service.list(context, first.value.datasetVersionId); + assert.equal(listed.length, 1); +}); + +void test('[DSM-011, IAM-009] sibling workspace cannot read profile disclosure', async () => { + const repository = new InMemoryDatasetProfileRepositoryAdapter(); + const service = new DatasetProfileService(repository); + const first = await service.register(context, input); + assert.equal(first.accepted, true); + const siblingResult = createIamTenantContextV1({ + ...context, + tenantScope: { + scopeType: 'workspace', + organizationId: context.tenantScope.organizationId, + workspaceId: '00000000-0000-4000-8000-000000000747', + }, + idempotencyKey: 'profile-sibling', + }); + assert.equal(siblingResult.accepted, true); + if (!siblingResult.accepted || !first.accepted) return; + const found = await service.find(siblingResult.value, first.value.profileId); + assert.deepEqual(found, { accepted: false, code: 'PROFILE_NOT_FOUND' }); +}); From ef433065ef2ae6fb944e8c2d34a4d5fe285d3e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:14:56 +0700 Subject: [PATCH 46/74] feat(dsm): expose profile disclosure API --- services/api/openapi/v1.json | 252 ++++++++++++++++++ .../dsm/api/dataset-profile.controller.ts | 60 +++++ .../features/dsm/api/dataset-profile.dto.ts | 97 +++++++ services/api/src/features/dsm/dsm.module.ts | 13 + .../dsm/dataset-profile.controller.test.ts | 85 ++++++ services/api/test/openapi.test.ts | 2 + 6 files changed, 509 insertions(+) create mode 100644 services/api/src/features/dsm/api/dataset-profile.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-profile.dto.ts create mode 100644 services/api/test/features/dsm/dataset-profile.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 9a832413..2c0bf385 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -4466,6 +4466,223 @@ "tags": ["datasets"] } }, + "/v1/dataset-profiles": { + "post": { + "operationId": "DatasetProfileController.register", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterDatasetProfileDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Register an immutable, value-free dataset profile disclosure", + "tags": ["datasets"] + }, + "get": { + "operationId": "DatasetProfileController.list", + "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List profile disclosures for one exact dataset version", + "tags": ["datasets"] + } + }, + "/v1/dataset-profiles/{profileId}": { + "get": { + "operationId": "DatasetProfileController.get", + "parameters": [ + { "name": "profileId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read an exact immutable dataset profile disclosure", + "tags": ["datasets"] + } + }, "/v1/devices/sync/operations": { "post": { "operationId": "DeviceSyncController.enqueue", @@ -6573,6 +6790,41 @@ "createdAt" ] }, + "DatasetProfileResourceLimitsDto": { + "type": "object", + "properties": { + "maxRows": { "type": "number", "minimum": 1, "maximum": 10000000 }, + "maxBytes": { "type": "number", "minimum": 1, "maximum": 1099511627776 }, + "maxDurationMs": { "type": "number", "minimum": 1, "maximum": 86400000 } + }, + "required": ["maxRows", "maxBytes", "maxDurationMs"] + }, + "RegisterDatasetProfileDto": { + "type": "object", + "properties": { + "profileId": { "type": "string", "format": "uuid" }, + "datasetVersionId": { "type": "string", "format": "uuid" }, + "completeness": { "type": "string", "enum": ["COMPLETE", "DETERMINISTIC_SAMPLE"] }, + "samplingMethod": { "type": "string", "maxLength": 96 }, + "samplingSeed": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "excludedScopes": { "maxItems": 64, "type": "array", "items": { "type": "string" } }, + "rowCountScanned": { "type": "number", "minimum": 0 }, + "rowCountAvailable": { "type": "number", "minimum": 0 }, + "resourceLimits": { "$ref": "#/components/schemas/DatasetProfileResourceLimitsDto" }, + "profileFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "profileId", + "datasetVersionId", + "completeness", + "samplingMethod", + "rowCountScanned", + "resourceLimits", + "profileFingerprint", + "createdAt" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/src/features/dsm/api/dataset-profile.controller.ts b/services/api/src/features/dsm/api/dataset-profile.controller.ts new file mode 100644 index 00000000..a780bb82 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-profile.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + DATASET_PROFILE_REPOSITORY_PORT, + type DatasetProfileRepositoryPortV1, +} from '../application/dataset-profile-repository.port.js'; +import { DatasetProfileService } from '../application/dataset-profile.service.js'; +import { RegisterDatasetProfileDto } from './dataset-profile.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-profiles') +export class DatasetProfileController { + private readonly profiles: DatasetProfileService; + + public constructor( + @Inject(DATASET_PROFILE_REPOSITORY_PORT) repository: DatasetProfileRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.profiles = new DatasetProfileService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable, value-free dataset profile disclosure' }) + @ApiBody({ type: RegisterDatasetProfileDto }) + async register( + @Req() request: unknown, + @Body() input: RegisterDatasetProfileDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.profiles.register(context, { ...input, tenantScope: context.tenantScope }); + } + + @Get(':profileId') + @ApiOperation({ summary: 'Read an exact immutable dataset profile disclosure' }) + async get(@Req() request: unknown, @Param('profileId') profileIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const profileId = parseStableIdentifierV1(profileIdInput); + if (!profileId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.profiles.find(context, profileId.value); + } + + @Get() + @ApiOperation({ summary: 'List profile disclosures for one exact dataset version' }) + async list( + @Req() request: unknown, + @Query('datasetVersionId') datasetVersionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetVersionId = parseStableIdentifierV1(datasetVersionIdInput); + if (!datasetVersionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.profiles.list(context, datasetVersionId.value); + } +} diff --git a/services/api/src/features/dsm/api/dataset-profile.dto.ts b/services/api/src/features/dsm/api/dataset-profile.dto.ts new file mode 100644 index 00000000..cbc7561c --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-profile.dto.ts @@ -0,0 +1,97 @@ +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export class DatasetProfileResourceLimitsDto { + @ApiProperty({ minimum: 1, maximum: 10000000 }) + @IsInt() + @Min(1) + @Max(10000000) + maxRows!: number; + + @ApiProperty({ minimum: 1, maximum: 1099511627776 }) + @IsInt() + @Min(1) + @Max(1099511627776) + maxBytes!: number; + + @ApiProperty({ minimum: 1, maximum: 86400000 }) + @IsInt() + @Min(1) + @Max(86400000) + maxDurationMs!: number; +} + +export class RegisterDatasetProfileDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + profileId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetVersionId!: string; + + @ApiProperty({ enum: ['COMPLETE', 'DETERMINISTIC_SAMPLE'] }) + @IsIn(['COMPLETE', 'DETERMINISTIC_SAMPLE']) + completeness!: 'COMPLETE' | 'DETERMINISTIC_SAMPLE'; + + @ApiProperty({ maxLength: 96 }) + @IsString() + @MaxLength(96) + samplingMethod!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$', required: false }) + @IsOptional() + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + samplingSeed?: string; + + @ApiProperty({ type: [String], maxItems: 64, required: false }) + @IsOptional() + @IsArray() + @ArrayMaxSize(64) + @IsString({ each: true }) + @MaxLength(128, { each: true }) + excludedScopes?: string[]; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCountScanned!: number; + + @ApiProperty({ minimum: 0, required: false }) + @IsOptional() + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCountAvailable?: number; + + @ApiProperty({ type: DatasetProfileResourceLimitsDto }) + @ValidateNested() + @Type(() => DatasetProfileResourceLimitsDto) + resourceLimits!: DatasetProfileResourceLimitsDto; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + profileFingerprint!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 00f4d99d..cb120765 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -6,6 +6,8 @@ import { ReferenceEntityController } from './api/reference-entity.controller.js' import { RuleSetController } from './api/rule-set.controller.js'; import { DatasetVersionController } from './api/dataset-version.controller.js'; import { DatasetQualityController } from './api/dataset-quality.controller.js'; +import { DatasetProfileController } from './api/dataset-profile.controller.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -60,6 +62,10 @@ import { DATASET_QUALITY_REPOSITORY_PORT, type DatasetQualityRepositoryPortV1, } from './application/dataset-quality-repository.port.js'; +import { + DATASET_PROFILE_REPOSITORY_PORT, + type DatasetProfileRepositoryPortV1, +} from './application/dataset-profile-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -85,6 +91,7 @@ export interface DsmModuleOptions { readonly datasetQualityRepository?: DatasetQualityRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetQualityDatabase?: DatasetQualityDatabaseClientV1; + readonly datasetProfileRepository?: DatasetProfileRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -100,6 +107,7 @@ export class DsmModule { ReferenceEntityController, DatasetVersionController, DatasetQualityController, + DatasetProfileController, ], providers: [ { @@ -150,6 +158,11 @@ export class DsmModule { ? new InMemoryDatasetQualityRepositoryAdapter() : new PrismaDatasetQualityRepositoryAdapter(options.datasetQualityDatabase)), }, + { + provide: DATASET_PROFILE_REPOSITORY_PORT, + useValue: + options.datasetProfileRepository ?? new InMemoryDatasetProfileRepositoryAdapter(), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/dataset-profile.controller.test.ts b/services/api/test/features/dsm/dataset-profile.controller.test.ts new file mode 100644 index 00000000..c0184520 --- /dev/null +++ b/services/api/test/features/dsm/dataset-profile.controller.test.ts @@ -0,0 +1,85 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000751', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000752', + workspaceId: '00000000-0000-4000-8000-000000000753', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000754', + idempotencyKey: 'profile-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('[DSM-011, IAM-009] profile HTTP surface discloses sampling and resource limits without values', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetProfileRepository: new InMemoryDatasetProfileRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-profiles', + payload: { + profileId: '00000000-0000-4000-8000-000000000755', + datasetVersionId: '00000000-0000-4000-8000-000000000756', + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 50, + rowCountAvailable: 100, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + sourceValue: 'must-not-be-accepted', + }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, /must-not-be-accepted/u); + + const accepted = await app.inject({ + method: 'POST', + url: '/v1/dataset-profiles', + payload: { + profileId: '00000000-0000-4000-8000-000000000755', + datasetVersionId: '00000000-0000-4000-8000-000000000756', + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 50, + rowCountAvailable: 100, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(accepted.statusCode, 201); + assert.match(accepted.body, /DETERMINISTIC_SAMPLE/u); + assert.doesNotMatch(accepted.body, /sourceValue|rawValue|path/u); + + const listed = await app.inject({ + method: 'GET', + url: '/v1/dataset-profiles?datasetVersionId=00000000-0000-4000-8000-000000000756', + }); + assert.equal(listed.statusCode, 200); + const body: unknown = JSON.parse(listed.body); + assert.ok(Array.isArray(body)); + assert.equal(body.length, 1); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index c497d07f..6f9c2882 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -93,6 +93,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-profiles', + '/v1/dataset-profiles/{profileId}', '/v1/dataset-quality-results', '/v1/dataset-quality-results/{resultId}', '/v1/dataset-versions', From 384213be5e64817ac9f899a0f2a194eab39cd95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:19:32 +0700 Subject: [PATCH 47/74] feat(dsm): persist profile disclosures with Prisma --- .../20260802270000_dsm_profiles/migration.sql | 28 +++ services/api/prisma/schema/dsm.prisma | 26 +++ ...isma-dataset-profile-repository.adapter.ts | 214 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 11 +- .../prisma-dataset-profile-repository.test.ts | 96 ++++++++ services/api/test/prisma-foundation.test.mjs | 14 ++ 6 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts diff --git a/services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql b/services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql new file mode 100644 index 00000000..920f8469 --- /dev/null +++ b/services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql @@ -0,0 +1,28 @@ +-- DSM-011: persist value-free profile disclosure metadata. +CREATE TABLE "dsm"."dataset_profiles" ( + "id" UUID NOT NULL, + "dataset_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "completeness" VARCHAR(32) NOT NULL, + "sampling_method" VARCHAR(96) NOT NULL, + "sampling_seed" CHAR(64), + "excluded_scopes" JSONB NOT NULL, + "row_count_scanned" BIGINT NOT NULL, + "row_count_available" BIGINT, + "max_rows" BIGINT NOT NULL, + "max_bytes" BIGINT NOT NULL, + "max_duration_ms" BIGINT NOT NULL, + "profile_fingerprint" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dataset_profiles_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "dataset_profiles_dataset_version_idx" + ON "dsm"."dataset_profiles"("dataset_version_id"); + +CREATE INDEX "dataset_profiles_scope_idx" + ON "dsm"."dataset_profiles"("organization_id", "workspace_id", "project_id", "dataset_version_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index 749b55d6..082f0833 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -72,6 +72,32 @@ model DatasetQualityResultRecord { @@schema("dsm") } +/// DSM-011: immutable disclosure of profiling completeness, sampling, exclusions, and budgets. +model DatasetProfileRecord { + id String @id @db.Uuid + datasetVersionId String @map("dataset_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + completeness String @db.VarChar(32) + samplingMethod String @map("sampling_method") @db.VarChar(96) + samplingSeed String? @map("sampling_seed") @db.Char(64) + excludedScopes Json @map("excluded_scopes") + rowCountScanned BigInt @map("row_count_scanned") + rowCountAvailable BigInt? @map("row_count_available") + maxRows BigInt @map("max_rows") + maxBytes BigInt @map("max_bytes") + maxDurationMs BigInt @map("max_duration_ms") + profileFingerprint String @map("profile_fingerprint") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([datasetVersionId], map: "dataset_profiles_dataset_version_idx") + @@index([organizationId, workspaceId, projectId, datasetVersionId], map: "dataset_profiles_scope_idx") + @@map("dataset_profiles") + @@schema("dsm") +} + /// DSM-025: canonical workspace reference identities are versioned and immutable. model ReferenceEntityVersionRecord { id String @id @db.Uuid diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts new file mode 100644 index 00000000..f5f8c195 --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts @@ -0,0 +1,214 @@ +import { + createDatasetProfileV1, + type DatasetProfileV1, +} from '@databreeze/domain/dataset-profile/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetProfileRepositoryPortV1, + DatasetProfileTransactionPortV1, +} from '../application/dataset-profile-repository.port.js'; + +export interface DatasetProfileDatabaseRowV1 { + readonly id: string; + readonly datasetVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly completeness: string; + readonly samplingMethod: string; + readonly samplingSeed: string | null; + readonly excludedScopes: unknown; + readonly rowCountScanned: bigint | number; + readonly rowCountAvailable: bigint | number | null; + readonly maxRows: bigint | number; + readonly maxBytes: bigint | number; + readonly maxDurationMs: bigint | number; + readonly profileFingerprint: string; + readonly createdAt: Date; +} + +export interface DatasetProfileDatabaseCreateDataV1 + extends Omit< + DatasetProfileDatabaseRowV1, + 'rowCountScanned' | 'rowCountAvailable' | 'maxRows' | 'maxBytes' | 'maxDurationMs' | 'createdAt' + > { + readonly rowCountScanned: bigint; + readonly rowCountAvailable: bigint | null; + readonly maxRows: bigint; + readonly maxBytes: bigint; + readonly maxDurationMs: bigint; + readonly createdAt: Date; +} + +export interface DatasetProfileDatabaseClientV1 { + readonly datasetProfileRecord: { + create(input: { + readonly data: DatasetProfileDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetProfileDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: DatasetProfileDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function numberValue(value: bigint | number): number { + const normalized = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(normalized)) throw new Error('DSM_PERSISTED_PROFILE_COUNT_INVALID'); + return normalized; +} + +function rowToDomain(row: DatasetProfileDatabaseRowV1): DatasetProfileV1 { + const parsed = createDatasetProfileV1({ + profileId: row.id, + datasetVersionId: row.datasetVersionId, + tenantScope: rowScope(row), + completeness: row.completeness, + samplingMethod: row.samplingMethod, + ...(row.samplingSeed === null ? {} : { samplingSeed: row.samplingSeed }), + excludedScopes: row.excludedScopes, + rowCountScanned: numberValue(row.rowCountScanned), + ...(row.rowCountAvailable === null + ? {} + : { rowCountAvailable: numberValue(row.rowCountAvailable) }), + resourceLimits: { + maxRows: numberValue(row.maxRows), + maxBytes: numberValue(row.maxBytes), + maxDurationMs: numberValue(row.maxDurationMs), + }, + profileFingerprint: row.profileFingerprint, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_PROFILE_INVALID'); + return parsed.value; +} + +function domainToCreate(profile: DatasetProfileV1): DatasetProfileDatabaseCreateDataV1 { + return { + ...databaseScope(profile.tenantScope), + id: profile.profileId, + datasetVersionId: profile.datasetVersionId, + completeness: profile.completeness, + samplingMethod: profile.samplingMethod, + samplingSeed: profile.samplingSeed ?? null, + excludedScopes: profile.excludedScopes, + rowCountScanned: BigInt(profile.rowCountScanned), + rowCountAvailable: + profile.rowCountAvailable === undefined ? null : BigInt(profile.rowCountAvailable), + maxRows: BigInt(profile.resourceLimits.maxRows), + maxBytes: BigInt(profile.resourceLimits.maxBytes), + maxDurationMs: BigInt(profile.resourceLimits.maxDurationMs), + profileFingerprint: profile.profileFingerprint, + createdAt: new Date(profile.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: DatasetProfileDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetProfileTransactionAdapter implements DatasetProfileTransactionPortV1 { + public constructor(private readonly client: DatasetProfileDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetProfileRecord.findUnique({ + where: { id: profile.profileId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(profile)) + throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); + return; + } + await this.client.datasetProfileRecord.create({ data: domainToCreate(profile) }); + } + + public async find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise { + const row = await this.client.datasetProfileRecord.findUnique({ where: { id: profileId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + const rows = await this.client.datasetProfileRecord.findMany({ + where: { datasetVersionId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaDatasetProfileRepositoryAdapter implements DatasetProfileRepositoryPortV1 { + public constructor(private readonly client: DatasetProfileDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetProfileTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetProfileTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise { + return new PrismaDatasetProfileTransactionAdapter(this.client).save(context, profile); + } + + public find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise { + return new PrismaDatasetProfileTransactionAdapter(this.client).find(context, profileId); + } + + public list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + return new PrismaDatasetProfileTransactionAdapter(this.client).list(context, datasetVersionId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index cb120765..d44e3da1 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -8,6 +8,10 @@ import { DatasetVersionController } from './api/dataset-version.controller.js'; import { DatasetQualityController } from './api/dataset-quality.controller.js'; import { DatasetProfileController } from './api/dataset-profile.controller.js'; import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; +import { + PrismaDatasetProfileRepositoryAdapter, + type DatasetProfileDatabaseClientV1, +} from './adapter/prisma-dataset-profile-repository.adapter.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -92,6 +96,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetQualityDatabase?: DatasetQualityDatabaseClientV1; readonly datasetProfileRepository?: DatasetProfileRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetProfileDatabase?: DatasetProfileDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -161,7 +167,10 @@ export class DsmModule { { provide: DATASET_PROFILE_REPOSITORY_PORT, useValue: - options.datasetProfileRepository ?? new InMemoryDatasetProfileRepositoryAdapter(), + options.datasetProfileRepository ?? + (options.datasetProfileDatabase === undefined + ? new InMemoryDatasetProfileRepositoryAdapter() + : new PrismaDatasetProfileRepositoryAdapter(options.datasetProfileDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts new file mode 100644 index 00000000..2488ab5d --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts @@ -0,0 +1,96 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { createDatasetProfileV1 } from '@databreeze/domain/dataset-profile/v1'; +import { + PrismaDatasetProfileRepositoryAdapter, + type DatasetProfileDatabaseClientV1, + type DatasetProfileDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000761'); +const workspaceId = id('00000000-0000-4000-8000-000000000762'); +const profileId = id('00000000-0000-4000-8000-000000000763'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000764', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000765', + idempotencyKey: 'prisma-profile', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: DatasetProfileDatabaseRowV1[]): DatasetProfileDatabaseClientV1 { + return { + datasetProfileRecord: { + create({ data }) { + const persisted = { ...data } as DatasetProfileDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.datasetVersionId === where['datasetVersionId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-011, IAM-009] Prisma profile adapter persists immutable disclosure and hides siblings', async () => { + const tenantContext = context(); + const created = createDatasetProfileV1({ + profileId, + datasetVersionId: '00000000-0000-4000-8000-000000000766', + tenantScope: tenantContext.tenantScope, + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 5, + rowCountAvailable: 10, + resourceLimits: { maxRows: 100, maxBytes: 1000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const rows: DatasetProfileDatabaseRowV1[] = []; + const repository = new PrismaDatasetProfileRepositoryAdapter(client(rows)); + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, profileId), created.value); + assert.deepEqual(await repository.list(tenantContext, created.value.datasetVersionId), [ + created.value, + ]); + assert.equal(rows.length, 1); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index fc83978a..0acc588d 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -63,6 +63,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_definitions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_profiles"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -112,6 +113,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802240000_iae_upload_sessions', '20260802250000_dsm_quality_results', '20260802260000_iae_inbox_metadata', + '20260802270000_dsm_profiles', 'migration_lock.toml', ]); const migration = await readFile( @@ -429,4 +431,16 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const profileMigration = await readFile( + path.join(migrationsDirectory, inventory[28], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "dsm"."dataset_profiles"', + 'CREATE INDEX "dataset_profiles_dataset_version_idx"', + '"sampling_method" VARCHAR(96)', + '"max_duration_ms" BIGINT', + ]) { + assert.match(profileMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } }); From e415fa7d1b53902d74e2104fc367576e2fc270ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:20:56 +0700 Subject: [PATCH 48/74] feat(dsm): add typed safe finding subjects --- packages/domain/src/dataset-quality/v1.ts | 104 +++++++++++++++++- .../domain/test/dataset-quality-v1.test.mjs | 40 +++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/dataset-quality/v1.ts b/packages/domain/src/dataset-quality/v1.ts index 31d7f855..60d76374 100644 --- a/packages/domain/src/dataset-quality/v1.ts +++ b/packages/domain/src/dataset-quality/v1.ts @@ -13,6 +13,33 @@ export const DATASET_QUALITY_SCHEMA_VERSION_V1 = 1 as const; export type DatasetQualityFindingSeverityV1 = 'INFO' | 'WARNING' | 'ERROR'; +export type DatasetQualitySafeValueKindV1 = + | 'TEXT' + | 'INTEGER' + | 'DECIMAL' + | 'BOOLEAN' + | 'DATE' + | 'MISSING' + | 'NULL' + | 'BLANK' + | 'INVALID' + | 'ZERO' + | 'NOT_APPLICABLE' + | 'REDACTED'; + +export interface DatasetQualitySafeValueV1 { + readonly kind: DatasetQualitySafeValueKindV1; + readonly value?: string | number | boolean; +} + +export type DatasetQualitySubjectTypeV1 = 'DATASET' | 'ROW' | 'FIELD' | 'CELL'; + +export interface DatasetQualityFindingSubjectV1 { + readonly type: DatasetQualitySubjectTypeV1; + readonly keyHash: string; + readonly fieldId?: StableIdentifierV1; +} + export interface DatasetQualityFindingV1 { readonly findingId: StableIdentifierV1; readonly ruleId: StableIdentifierV1; @@ -21,6 +48,9 @@ export interface DatasetQualityFindingV1 { readonly occurrenceCount: number; readonly evidenceIds: readonly StableIdentifierV1[]; readonly detailHash: string; + readonly subject?: DatasetQualityFindingSubjectV1; + readonly actual?: DatasetQualitySafeValueV1; + readonly expected?: DatasetQualitySafeValueV1; } export interface DatasetQualityResultV1 { @@ -47,7 +77,8 @@ export type DatasetQualityErrorCodeV1 = | 'INVALID_TEXT' | 'INVALID_FINDING' | 'DUPLICATE_FINDING' - | 'INVALID_QUALITY_STATE'; + | 'INVALID_QUALITY_STATE' + | 'INVALID_TYPED_VALUE'; export type DatasetQualityResultV1Of = | { readonly accepted: true; readonly value: TValue } @@ -93,6 +124,68 @@ function positiveCount(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; } +const safeValueKinds: readonly DatasetQualitySafeValueKindV1[] = [ + 'TEXT', + 'INTEGER', + 'DECIMAL', + 'BOOLEAN', + 'DATE', + 'MISSING', + 'NULL', + 'BLANK', + 'INVALID', + 'ZERO', + 'NOT_APPLICABLE', + 'REDACTED', +]; + +function safeValue(input: unknown): DatasetQualitySafeValueV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const kind = record['kind']; + if (!safeValueKinds.includes(kind as DatasetQualitySafeValueKindV1)) return undefined; + const value = record['value']; + if (value === undefined) { + if (['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes(kind as string)) + return undefined; + return Object.freeze({ kind: kind as DatasetQualitySafeValueKindV1 }); + } + if (typeof value === 'string') { + if (value.length === 0 || value.length > 256 || /\p{Cc}/u.test(value)) return undefined; + return Object.freeze({ + kind: kind as DatasetQualitySafeValueKindV1, + value: value.normalize('NFC'), + }); + } + if (typeof value === 'boolean') { + if (kind !== 'BOOLEAN') return undefined; + return Object.freeze({ kind: 'BOOLEAN', value }); + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || (!Number.isSafeInteger(value) && kind === 'INTEGER')) + return undefined; + if (!['INTEGER', 'DECIMAL'].includes(kind as string)) return undefined; + return Object.freeze({ kind: kind as DatasetQualitySafeValueKindV1, value }); + } + return undefined; +} + +function subject(input: unknown): DatasetQualityFindingSubjectV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const type = record['type']; + const keyHash = hash(record['keyHash']); + const fieldId = record['fieldId'] === undefined ? undefined : identifier(record['fieldId']); + if (!['DATASET', 'ROW', 'FIELD', 'CELL'].includes(type as string) || !keyHash) return undefined; + if (record['fieldId'] !== undefined && !fieldId) return undefined; + if (['FIELD', 'CELL'].includes(type as string) && !fieldId) return undefined; + return Object.freeze({ + type: type as DatasetQualitySubjectTypeV1, + keyHash, + ...(fieldId === undefined ? {} : { fieldId }), + }); +} + function finding(input: unknown): DatasetQualityFindingV1 | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; const record = input as Record; @@ -103,6 +196,9 @@ function finding(input: unknown): DatasetQualityFindingV1 | undefined { const occurrenceCount = positiveCount(record['occurrenceCount']); const detailHash = hash(record['detailHash']); const evidenceInput = record['evidenceIds'] ?? []; + const parsedSubject = record['subject'] === undefined ? undefined : subject(record['subject']); + const actual = record['actual'] === undefined ? undefined : safeValue(record['actual']); + const expected = record['expected'] === undefined ? undefined : safeValue(record['expected']); if (!findingId || !ruleId || !messageCode || occurrenceCount === undefined || !detailHash) { return undefined; } @@ -112,6 +208,9 @@ function finding(input: unknown): DatasetQualityFindingV1 | undefined { if (evidenceIds.some((candidate): candidate is undefined => candidate === undefined)) { return undefined; } + if (record['subject'] !== undefined && !parsedSubject) return undefined; + if (record['actual'] !== undefined && !actual) return undefined; + if (record['expected'] !== undefined && !expected) return undefined; return Object.freeze({ findingId, ruleId, @@ -120,6 +219,9 @@ function finding(input: unknown): DatasetQualityFindingV1 | undefined { occurrenceCount, evidenceIds: Object.freeze(evidenceIds as StableIdentifierV1[]), detailHash, + ...(parsedSubject === undefined ? {} : { subject: parsedSubject }), + ...(actual === undefined ? {} : { actual }), + ...(expected === undefined ? {} : { expected }), }); } diff --git a/packages/domain/test/dataset-quality-v1.test.mjs b/packages/domain/test/dataset-quality-v1.test.mjs index e83b9293..2d7a621e 100644 --- a/packages/domain/test/dataset-quality-v1.test.mjs +++ b/packages/domain/test/dataset-quality-v1.test.mjs @@ -89,3 +89,43 @@ void test('[DSM-013] quality result validation rejects malformed hashes, counts, { accepted: false, code: 'DUPLICATE_FINDING' }, ); }); + +void test('[DSM-013] findings may carry bounded typed values and hashed subjects only', () => { + const created = result({ + findings: [ + { + findingId: ids.findingId, + ruleId: ids.ruleId, + severity: 'WARNING', + messageCode: 'NULL_RATE_HIGH', + occurrenceCount: 2, + evidenceIds: [ids.evidenceId], + detailHash: 'b'.repeat(64), + subject: { + type: 'FIELD', + keyHash: 'd'.repeat(64), + fieldId: '00000000-0000-4000-8000-000000000017', + }, + actual: { kind: 'DECIMAL', value: 0.42 }, + expected: { kind: 'DECIMAL', value: 0.1 }, + }, + ], + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.findings[0].subject?.type, 'FIELD'); + assert.deepEqual(created.value.findings[0].actual, { kind: 'DECIMAL', value: 0.42 }); + const base = result(); + assert.equal(base.accepted, true); + if (!base.accepted) return; + const invalid = createDatasetQualityResultV1({ + ...base.value, + findings: [ + { + ...created.value.findings[0], + actual: { kind: 'TEXT', value: 'a'.repeat(257) }, + }, + ], + }); + assert.deepEqual(invalid, { accepted: false, code: 'INVALID_FINDING' }); +}); From 261c8afa0d14078d00e5198f35ed20157cd31747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:24:20 +0700 Subject: [PATCH 49/74] feat(dsm): expose typed safe finding values --- services/api/openapi/v1.json | 42 +++++++++- .../features/dsm/api/dataset-quality.dto.ts | 78 +++++++++++++++++++ .../src/features/iae/api/inbox-item.dto.ts | 10 ++- .../dsm/dataset-quality.controller.test.ts | 7 ++ 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 2c0bf385..8806a5c9 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6397,10 +6397,10 @@ "UpdateInboxMetadataDto": { "type": "object", "properties": { - "assigneeId": { "type": "string", "format": "uuid", "nullable": true }, + "assigneeId": { "oneOf": [{ "type": "string", "format": "uuid" }, { "type": "null" }] }, "labels": { "maxItems": 32, "type": "array", "items": { "type": "string" } }, "priority": { "type": "string", "enum": ["LOW", "NORMAL", "HIGH", "URGENT"] }, - "dueAt": { "type": "string", "format": "date-time", "nullable": true }, + "dueAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, "expectedRevision": { "type": "number", "minimum": 1 } } }, @@ -6736,6 +6736,39 @@ "lineageManifestHash" ] }, + "DatasetQualityFindingSubjectDto": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["DATASET", "ROW", "FIELD", "CELL"] }, + "keyHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "fieldId": { "type": "string", "format": "uuid" } + }, + "required": ["type", "keyHash"] + }, + "DatasetQualitySafeValueDto": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "TEXT", + "INTEGER", + "DECIMAL", + "BOOLEAN", + "DATE", + "MISSING", + "NULL", + "BLANK", + "INVALID", + "ZERO", + "NOT_APPLICABLE", + "REDACTED" + ] + }, + "value": { "oneOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }] } + }, + "required": ["kind"] + }, "DatasetQualityFindingDto": { "type": "object", "properties": { @@ -6745,7 +6778,10 @@ "messageCode": { "type": "string", "minLength": 1, "maxLength": 96 }, "occurrenceCount": { "type": "number", "minimum": 0 }, "evidenceIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, - "detailHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + "detailHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "subject": { "$ref": "#/components/schemas/DatasetQualityFindingSubjectDto" }, + "actual": { "$ref": "#/components/schemas/DatasetQualitySafeValueDto" }, + "expected": { "$ref": "#/components/schemas/DatasetQualitySafeValueDto" } }, "required": [ "findingId", diff --git a/services/api/src/features/dsm/api/dataset-quality.dto.ts b/services/api/src/features/dsm/api/dataset-quality.dto.ts index 1863646d..d9293b70 100644 --- a/services/api/src/features/dsm/api/dataset-quality.dto.ts +++ b/services/api/src/features/dsm/api/dataset-quality.dto.ts @@ -2,11 +2,13 @@ import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, + Allow, IsArray, IsIn, IsInt, IsString, IsUUID, + IsOptional, Matches, Max, MaxLength, @@ -15,6 +17,64 @@ import { ValidateNested, } from 'class-validator'; +export class DatasetQualitySafeValueDto { + @ApiProperty({ + enum: [ + 'TEXT', + 'INTEGER', + 'DECIMAL', + 'BOOLEAN', + 'DATE', + 'MISSING', + 'NULL', + 'BLANK', + 'INVALID', + 'ZERO', + 'NOT_APPLICABLE', + 'REDACTED', + ], + }) + @IsIn([ + 'TEXT', + 'INTEGER', + 'DECIMAL', + 'BOOLEAN', + 'DATE', + 'MISSING', + 'NULL', + 'BLANK', + 'INVALID', + 'ZERO', + 'NOT_APPLICABLE', + 'REDACTED', + ]) + kind!: string; + + @ApiProperty({ + required: false, + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }) + @IsOptional() + @Allow() + value?: string | number | boolean; +} + +export class DatasetQualityFindingSubjectDto { + @ApiProperty({ enum: ['DATASET', 'ROW', 'FIELD', 'CELL'] }) + @IsIn(['DATASET', 'ROW', 'FIELD', 'CELL']) + type!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + keyHash!: string; + + @ApiProperty({ format: 'uuid', required: false }) + @IsOptional() + @IsUUID() + fieldId?: string; +} + export class DatasetQualityFindingDto { @ApiProperty({ format: 'uuid' }) @IsUUID() @@ -50,6 +110,24 @@ export class DatasetQualityFindingDto { @IsString() @Matches(/^[0-9a-f]{64}$/u) detailHash!: string; + + @ApiProperty({ type: DatasetQualityFindingSubjectDto, required: false }) + @IsOptional() + @ValidateNested() + @Type(() => DatasetQualityFindingSubjectDto) + subject?: DatasetQualityFindingSubjectDto; + + @ApiProperty({ type: DatasetQualitySafeValueDto, required: false }) + @IsOptional() + @ValidateNested() + @Type(() => DatasetQualitySafeValueDto) + actual?: DatasetQualitySafeValueDto; + + @ApiProperty({ type: DatasetQualitySafeValueDto, required: false }) + @IsOptional() + @ValidateNested() + @Type(() => DatasetQualitySafeValueDto) + expected?: DatasetQualitySafeValueDto; } export class RegisterDatasetQualityResultDto { diff --git a/services/api/src/features/iae/api/inbox-item.dto.ts b/services/api/src/features/iae/api/inbox-item.dto.ts index bcec3d00..011d826a 100644 --- a/services/api/src/features/iae/api/inbox-item.dto.ts +++ b/services/api/src/features/iae/api/inbox-item.dto.ts @@ -38,7 +38,10 @@ export class CreateInboxItemDto { /** IAE-013: revisioned, content-free inbox triage metadata patch. */ export class UpdateInboxMetadataDto { - @ApiProperty({ type: String, format: 'uuid', nullable: true, required: false }) + @ApiProperty({ + oneOf: [{ type: 'string', format: 'uuid' }, { type: 'null' }], + required: false, + }) @IsOptional() @IsUUID() assigneeId?: string | null; @@ -57,7 +60,10 @@ export class UpdateInboxMetadataDto { @IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT']) priority?: InboxPriorityV1; - @ApiProperty({ type: String, format: 'date-time', nullable: true, required: false }) + @ApiProperty({ + oneOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }], + required: false, + }) @IsOptional() @IsISO8601() dueAt?: string | null; diff --git a/services/api/test/features/dsm/dataset-quality.controller.test.ts b/services/api/test/features/dsm/dataset-quality.controller.test.ts index 81b8f47e..8d090ccf 100644 --- a/services/api/test/features/dsm/dataset-quality.controller.test.ts +++ b/services/api/test/features/dsm/dataset-quality.controller.test.ts @@ -54,6 +54,13 @@ void test('[DSM-011, DSM-013, DSM-015] quality HTTP surfaces never accept source occurrenceCount: 3, evidenceIds: [], detailHash: 'b'.repeat(64), + subject: { + type: 'FIELD', + keyHash: 'd'.repeat(64), + fieldId: '00000000-0000-4000-8000-000000000931', + }, + actual: { kind: 'DECIMAL', value: 0.42 }, + expected: { kind: 'DECIMAL', value: 0.1 }, }, ], resultFingerprint: 'c'.repeat(64), From 083e0764aeac926bab0a9ab04a0e55d91af58580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:26:46 +0700 Subject: [PATCH 50/74] feat(dsm): add stable profile pagination --- .../application/dataset-profile.service.ts | 25 +++++++ .../dsm/dataset-profile.pagination.test.ts | 70 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 services/api/test/features/dsm/dataset-profile.pagination.test.ts diff --git a/services/api/src/features/dsm/application/dataset-profile.service.ts b/services/api/src/features/dsm/application/dataset-profile.service.ts index baee7f3a..90c6d5e4 100644 --- a/services/api/src/features/dsm/application/dataset-profile.service.ts +++ b/services/api/src/features/dsm/application/dataset-profile.service.ts @@ -12,6 +12,11 @@ export type DatasetProfileServiceResultV1 = | DatasetProfileResultV1 | { readonly accepted: false; readonly code: DatasetProfileServiceErrorV1 }; +export interface DatasetProfilePageV1 { + readonly items: readonly DatasetProfileV1[]; + readonly nextCursor?: DatasetProfileV1['profileId']; +} + /** Coordinates immutable, value-free profiling disclosure records. */ export class DatasetProfileService { public constructor(private readonly repository: DatasetProfileRepositoryPortV1) {} @@ -52,4 +57,24 @@ export class DatasetProfileService { transaction.list(context, datasetVersionId), ); } + + /** DSM-021: stable cursor pagination is scoped by the exact dataset version and tenant. */ + public async listPage( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + options: { readonly limit: number; readonly cursor?: DatasetProfileV1['profileId'] }, + ): Promise { + if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100) + throw new Error('DSM_INVALID_PAGE_LIMIT'); + const profiles = await this.list(context, datasetVersionId); + const cursor = options.cursor; + const after = + cursor === undefined ? profiles : profiles.filter((profile) => profile.profileId > cursor); + const items = after.slice(0, options.limit); + const last = items.at(-1); + return Object.freeze({ + items: Object.freeze(items), + ...(after.length > items.length && last !== undefined ? { nextCursor: last.profileId } : {}), + }); + } } diff --git a/services/api/test/features/dsm/dataset-profile.pagination.test.ts b/services/api/test/features/dsm/dataset-profile.pagination.test.ts new file mode 100644 index 00000000..aeecc609 --- /dev/null +++ b/services/api/test/features/dsm/dataset-profile.pagination.test.ts @@ -0,0 +1,70 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.js'; +import { DatasetProfileService } from '../../../src/features/dsm/application/dataset-profile.service.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000771', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000772', + workspaceId: '00000000-0000-4000-8000-000000000773', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000774', + idempotencyKey: 'profile-pagination', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const datasetVersionId = id('00000000-0000-4000-8000-000000000775'); + +function profile(profileId: StableIdentifierV1) { + return { + profileId, + datasetVersionId, + tenantScope: context.tenantScope, + completeness: 'COMPLETE' as const, + samplingMethod: 'FULL_SCAN_V1', + excludedScopes: [], + rowCountScanned: 10, + resourceLimits: { maxRows: 100, maxBytes: 1000, maxDurationMs: 60000 }, + profileFingerprint: 'a'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +void test('[DSM-021] profile page cursors are stable and bound to one dataset version', async () => { + const service = new DatasetProfileService(new InMemoryDatasetProfileRepositoryAdapter()); + await service.register(context, profile(id('00000000-0000-4000-8000-000000000776'))); + await service.register(context, profile(id('00000000-0000-4000-8000-000000000777'))); + const first = await service.listPage(context, datasetVersionId, { + limit: 1, + }); + assert.equal(first.items.length, 1); + const firstItem = first.items[0]; + assert.ok(firstItem); + assert.equal(first.nextCursor, firstItem.profileId); + const second = await service.listPage(context, datasetVersionId, { + limit: 1, + cursor: first.nextCursor, + }); + assert.deepEqual( + second.items.map((item) => item.profileId), + ['00000000-0000-4000-8000-000000000777'], + ); + assert.equal(second.nextCursor, undefined); +}); From ebadd9d48d3c89bd3576e84fe60eee8f40af9ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:29:34 +0700 Subject: [PATCH 51/74] feat(dsm): expose cursor-paginated profiles --- services/api/openapi/v1.json | 76 +++++++++++++++++++ .../dsm/api/dataset-profile.controller.ts | 23 ++++++ .../dsm/dataset-profile.controller.test.ts | 42 +++++++++- services/api/test/openapi.test.ts | 1 + 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 8806a5c9..39bbec63 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -4614,6 +4614,82 @@ "tags": ["datasets"] } }, + "/v1/dataset-profiles/page": { + "get": { + "operationId": "DatasetProfileController.page", + "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { "name": "limit", "required": true, "in": "query", "schema": { "type": "string" } }, + { "name": "cursor", "required": true, "in": "query", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List dataset profiles with a stable scoped cursor", + "tags": ["datasets"] + } + }, "/v1/dataset-profiles/{profileId}": { "get": { "operationId": "DatasetProfileController.get", diff --git a/services/api/src/features/dsm/api/dataset-profile.controller.ts b/services/api/src/features/dsm/api/dataset-profile.controller.ts index a780bb82..b6418a4d 100644 --- a/services/api/src/features/dsm/api/dataset-profile.controller.ts +++ b/services/api/src/features/dsm/api/dataset-profile.controller.ts @@ -37,6 +37,29 @@ export class DatasetProfileController { return this.profiles.register(context, { ...input, tenantScope: context.tenantScope }); } + @Get('page') + @ApiOperation({ summary: 'List dataset profiles with a stable scoped cursor' }) + async page( + @Req() request: unknown, + @Query('datasetVersionId') datasetVersionIdInput: string, + @Query('limit') limitInput?: string, + @Query('cursor') cursorInput?: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetVersionId = parseStableIdentifierV1(datasetVersionIdInput); + if (!datasetVersionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const limit = limitInput === undefined ? 50 : Number(limitInput); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) + return { accepted: false, code: 'INVALID_PAGE_LIMIT' as const }; + const cursor = cursorInput === undefined ? undefined : parseStableIdentifierV1(cursorInput); + if (cursorInput !== undefined && !cursor?.accepted) + return { accepted: false, code: 'INVALID_CURSOR' as const }; + return this.profiles.listPage(context, datasetVersionId.value, { + limit, + ...(cursor?.accepted ? { cursor: cursor.value } : {}), + }); + } + @Get(':profileId') @ApiOperation({ summary: 'Read an exact immutable dataset profile disclosure' }) async get(@Req() request: unknown, @Param('profileId') profileIdInput: string): Promise { diff --git a/services/api/test/features/dsm/dataset-profile.controller.test.ts b/services/api/test/features/dsm/dataset-profile.controller.test.ts index c0184520..89eb0e2b 100644 --- a/services/api/test/features/dsm/dataset-profile.controller.test.ts +++ b/services/api/test/features/dsm/dataset-profile.controller.test.ts @@ -71,6 +71,46 @@ void test('[DSM-011, IAM-009] profile HTTP surface discloses sampling and resour assert.match(accepted.body, /DETERMINISTIC_SAMPLE/u); assert.doesNotMatch(accepted.body, /sourceValue|rawValue|path/u); + const second = await app.inject({ + method: 'POST', + url: '/v1/dataset-profiles', + payload: { + profileId: '00000000-0000-4000-8000-000000000757', + datasetVersionId: '00000000-0000-4000-8000-000000000756', + completeness: 'COMPLETE', + samplingMethod: 'FULL_SCAN_V1', + rowCountScanned: 100, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(second.statusCode, 201); + + const firstPage = await app.inject({ + method: 'GET', + url: '/v1/dataset-profiles/page?datasetVersionId=00000000-0000-4000-8000-000000000756&limit=1', + }); + assert.equal(firstPage.statusCode, 200); + const firstPageBody = JSON.parse(firstPage.body) as { + readonly items: readonly { readonly profileId: string }[]; + readonly nextCursor?: string; + }; + assert.equal(firstPageBody.items.length, 1); + assert.equal(typeof firstPageBody.nextCursor, 'string'); + const secondPage = await app.inject({ + method: 'GET', + url: `/v1/dataset-profiles/page?datasetVersionId=00000000-0000-4000-8000-000000000756&limit=1&cursor=${firstPageBody.nextCursor}`, + }); + assert.equal(secondPage.statusCode, 200); + const secondPageBody = JSON.parse(secondPage.body) as { + readonly items: readonly { readonly profileId: string }[]; + }; + assert.deepEqual( + secondPageBody.items.map((item) => item.profileId), + ['00000000-0000-4000-8000-000000000757'], + ); + const listed = await app.inject({ method: 'GET', url: '/v1/dataset-profiles?datasetVersionId=00000000-0000-4000-8000-000000000756', @@ -78,7 +118,7 @@ void test('[DSM-011, IAM-009] profile HTTP surface discloses sampling and resour assert.equal(listed.statusCode, 200); const body: unknown = JSON.parse(listed.body); assert.ok(Array.isArray(body)); - assert.equal(body.length, 1); + assert.equal(body.length, 2); } finally { await app.close(); } diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 6f9c2882..e135b7e3 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -94,6 +94,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', '/v1/dataset-profiles', + '/v1/dataset-profiles/page', '/v1/dataset-profiles/{profileId}', '/v1/dataset-quality-results', '/v1/dataset-quality-results/{resultId}', From ddb7fd2492c0d50bfe0bfae11d8634fe9f66565a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:32:29 +0700 Subject: [PATCH 52/74] feat(iae): add opaque upload storage boundary --- ...-memory-artifact-upload-storage.adapter.ts | 121 ++++++++++++++++++ .../artifact-upload-storage.port.ts | 52 ++++++++ .../artifact-upload-storage.adapter.test.ts | 62 +++++++++ 3 files changed, 235 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts create mode 100644 services/api/src/features/iae/application/artifact-upload-storage.port.ts create mode 100644 services/api/test/features/iae/artifact-upload-storage.adapter.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts new file mode 100644 index 00000000..73d8f5a1 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto'; + +import type { + ArtifactUploadPartV1, + ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactUploadPartTransferV1, + ArtifactUploadStoragePortV1, + ArtifactUploadStorageResultV1, +} from '../application/artifact-upload-storage.port.js'; + +function accepted(value: TValue): ArtifactUploadStorageResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected( + code: Exclude, { readonly accepted: true }>['code'], +): ArtifactUploadStorageResultV1 { + return Object.freeze({ accepted: false, code }); +} + +/** Deterministic test adapter that never exposes an object before finalization. */ +export class InMemoryArtifactUploadStorageAdapter implements ArtifactUploadStoragePortV1 { + private transfers = new Map< + string, + { readonly sessionId: string; readonly partNumber: number } + >(); + private parts = new Map(); + private finalized = new Set(); + + public async issuePartTransfer( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + partNumber: number, + ): Promise> { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + return rejected('UPLOAD_STORAGE_SCOPE_DENIED'); + if ( + session.state !== 'OPEN' || + !Number.isSafeInteger(partNumber) || + partNumber < 1 || + partNumber > session.totalParts + ) + return rejected('UPLOAD_STORAGE_NOT_READY'); + const transferId = randomUUID(); + this.transfers.set(transferId, { sessionId: session.sessionId, partNumber }); + return accepted({ + transferId, + sessionId: session.sessionId, + partNumber, + expiresAt: session.expiresAt, + }); + } + + public async verifyPart( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + part: ArtifactUploadPartV1, + transferId?: string, + ): Promise> { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + return rejected('UPLOAD_STORAGE_SCOPE_DENIED'); + if (session.state !== 'OPEN') return rejected('UPLOAD_STORAGE_NOT_READY'); + if (transferId !== undefined) { + const transfer = this.transfers.get(transferId); + if ( + transfer === undefined || + transfer.sessionId !== session.sessionId || + transfer.partNumber !== part.partNumber + ) + return rejected('UPLOAD_STORAGE_TRANSFER_INVALID'); + this.transfers.delete(transferId); + } + const key = `${session.sessionId}:${part.partNumber}`; + const existing = this.parts.get(key); + if ( + existing && + (existing.contentSha256 !== part.contentSha256 || existing.byteSize !== part.byteSize) + ) + return rejected('UPLOAD_STORAGE_PART_REJECTED'); + this.parts.set(key, part); + return accepted(undefined); + } + + public async finalize( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + assembledSha256: string, + ): Promise> { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + return rejected('UPLOAD_STORAGE_SCOPE_DENIED'); + if (session.state !== 'OPEN') return rejected('UPLOAD_STORAGE_NOT_READY'); + if (assembledSha256 !== session.expectedSha256) + return rejected('UPLOAD_STORAGE_DIGEST_MISMATCH'); + if (session.parts.length !== session.totalParts) return rejected('UPLOAD_STORAGE_NOT_READY'); + for (const part of session.parts) { + if (!this.parts.has(`${session.sessionId}:${part.partNumber}`)) + return rejected('UPLOAD_STORAGE_NOT_READY'); + } + this.finalized.add(session.sessionId); + return accepted(undefined); + } + + public async abort(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) return; + for (const key of this.parts.keys()) + if (key.startsWith(`${session.sessionId}:`)) this.parts.delete(key); + for (const [transferId, transfer] of this.transfers) { + if (transfer.sessionId === session.sessionId) this.transfers.delete(transferId); + } + this.finalized.delete(session.sessionId); + } +} diff --git a/services/api/src/features/iae/application/artifact-upload-storage.port.ts b/services/api/src/features/iae/application/artifact-upload-storage.port.ts new file mode 100644 index 00000000..6b8e97d9 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-upload-storage.port.ts @@ -0,0 +1,52 @@ +import type { + ArtifactUploadPartV1, + ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_UPLOAD_STORAGE_PORT = Symbol('ARTIFACT_UPLOAD_STORAGE_PORT'); + +export interface ArtifactUploadPartTransferV1 { + readonly transferId: string; + readonly sessionId: ArtifactUploadSessionV1['sessionId']; + readonly partNumber: number; + readonly expiresAt: ArtifactUploadSessionV1['expiresAt']; +} + +export type ArtifactUploadStorageErrorCodeV1 = + | 'UPLOAD_STORAGE_SCOPE_DENIED' + | 'UPLOAD_STORAGE_NOT_READY' + | 'UPLOAD_STORAGE_TRANSFER_INVALID' + | 'UPLOAD_STORAGE_PART_REJECTED' + | 'UPLOAD_STORAGE_DIGEST_MISMATCH' + | 'UPLOAD_STORAGE_FINALIZATION_FAILED'; + +export type ArtifactUploadStorageResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactUploadStorageErrorCodeV1 }; + +/** + * Provider-neutral cloud object boundary. Implementations verify parts and + * publish the object only after final digest validation; no partial locator or + * raw bytes cross this port. + */ +export interface ArtifactUploadStoragePortV1 { + issuePartTransfer( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + partNumber: number, + ): Promise>; + verifyPart( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + part: ArtifactUploadPartV1, + transferId?: string, + ): Promise>; + finalize( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + assembledSha256: string, + ): Promise>; + abort(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise; +} diff --git a/services/api/test/features/iae/artifact-upload-storage.adapter.test.ts b/services/api/test/features/iae/artifact-upload-storage.adapter.test.ts new file mode 100644 index 00000000..ccd65598 --- /dev/null +++ b/services/api/test/features/iae/artifact-upload-storage.adapter.test.ts @@ -0,0 +1,62 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactUploadSessionV1 } from '@databreeze/domain/artifact-upload/v1'; +import { parseStrictUtcTimestampV1 } from '@databreeze/domain/tenant-scope/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryArtifactUploadStorageAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000781', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000782', + workspaceId: '00000000-0000-4000-8000-000000000783', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000784', + idempotencyKey: 'upload-storage', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const timestamp = parseStrictUtcTimestampV1('2026-01-01T00:10:00.000Z'); +if (!timestamp.accepted) throw new Error('fixture timestamp invalid'); + +void test('[IAE-014] storage adapter binds transfer grants to sessions and hides partial objects', async () => { + const session = createArtifactUploadSessionV1({ + sessionId: '00000000-0000-4000-8000-000000000785', + artifactId: '00000000-0000-4000-8000-000000000786', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }); + assert.equal(session.accepted, true); + if (!session.accepted) return; + const storage = new InMemoryArtifactUploadStorageAdapter(); + const grant = await storage.issuePartTransfer(context, session.value, 1); + assert.equal(grant.accepted, true); + if (!grant.accepted) return; + const part = { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: timestamp.value, + } as const; + assert.deepEqual( + await storage.verifyPart(context, session.value, part, '00000000-0000-4000-8000-000000000787'), + { accepted: false, code: 'UPLOAD_STORAGE_TRANSFER_INVALID' }, + ); + assert.deepEqual(await storage.verifyPart(context, session.value, part, grant.value.transferId), { + accepted: true, + value: undefined, + }); + assert.deepEqual(await storage.finalize(context, session.value, 'a'.repeat(64)), { + accepted: false, + code: 'UPLOAD_STORAGE_NOT_READY', + }); +}); From 56b61b1a5836ce4a1c77f37c127d1f276c3b729d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:38:37 +0700 Subject: [PATCH 53/74] feat(iae): verify uploads through storage boundary --- .../application/artifact-upload.service.ts | 67 ++++++++++++++++--- .../iae/artifact-upload.service.test.ts | 10 ++- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts index 8a3a8214..f4e12e51 100644 --- a/services/api/src/features/iae/application/artifact-upload.service.ts +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -11,15 +11,25 @@ import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { ArtifactUploadRepositoryPortV1 } from './artifact-upload-repository.port.js'; +import { InMemoryArtifactUploadStorageAdapter } from '../adapter/in-memory-artifact-upload-storage.adapter.js'; +import type { + ArtifactUploadPartTransferV1, + ArtifactUploadStoragePortV1, + ArtifactUploadStorageResultV1, +} from './artifact-upload-storage.port.js'; export type ArtifactUploadServiceErrorV1 = 'UPLOAD_NOT_FOUND' | 'UPLOAD_SCOPE_NARROWING_REQUIRED'; export type ArtifactUploadServiceResultV1 = | ArtifactUploadResultV1 + | ArtifactUploadStorageResultV1 | { readonly accepted: false; readonly code: ArtifactUploadServiceErrorV1 }; /** Coordinates revisioned upload state without accepting paths, URLs, or raw bytes. */ export class ArtifactUploadService { - public constructor(private readonly repository: ArtifactUploadRepositoryPortV1) {} + public constructor( + private readonly repository: ArtifactUploadRepositoryPortV1, + private readonly storage: ArtifactUploadStoragePortV1 = new InMemoryArtifactUploadStorageAdapter(), + ) {} public async create( context: IamTenantContextV1, @@ -43,9 +53,21 @@ export class ArtifactUploadService { public async recordPart( context: IamTenantContextV1, sessionId: ArtifactUploadSessionV1['sessionId'], - input: Parameters[1], + input: Parameters[1] & { readonly transferId?: string }, ): Promise> { - return this.mutate(context, sessionId, (session) => recordArtifactUploadPartV1(session, input)); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = recordArtifactUploadPartV1(current, input); + if (!next.accepted) return next; + const part = next.value.parts.find((candidate) => candidate.partNumber === input.partNumber); + if (!part) + return Object.freeze({ accepted: false, code: 'UPLOAD_STORAGE_PART_REJECTED' as const }); + const verified = await this.storage.verifyPart(context, current, part, input.transferId); + if (!verified.accepted) return verified; + await transaction.save(context, next.value); + return next; + }); } public async complete( @@ -53,9 +75,20 @@ export class ArtifactUploadService { sessionId: ArtifactUploadSessionV1['sessionId'], input: Parameters[1], ): Promise> { - return this.mutate(context, sessionId, (session) => - completeArtifactUploadSessionV1(session, input), - ); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = completeArtifactUploadSessionV1(current, input); + if (!next.accepted) return next; + const finalized = await this.storage.finalize( + context, + current, + input.assembledSha256 as string, + ); + if (!finalized.accepted) return finalized; + await transaction.save(context, next.value); + return next; + }); } public async abort( @@ -63,9 +96,15 @@ export class ArtifactUploadService { sessionId: ArtifactUploadSessionV1['sessionId'], expectedRevision: unknown, ): Promise> { - return this.mutate(context, sessionId, (session) => - abortArtifactUploadSessionV1(session, expectedRevision), - ); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = abortArtifactUploadSessionV1(current, expectedRevision); + if (!next.accepted) return next; + await this.storage.abort(context, current); + await transaction.save(context, next.value); + return next; + }); } public async expire( @@ -78,6 +117,16 @@ export class ArtifactUploadService { ); } + public async issuePartTransfer( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + partNumber: number, + ): Promise> { + const session = await this.repository.find(context, sessionId); + if (!session) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + return this.storage.issuePartTransfer(context, session, partNumber); + } + private async mutate( context: IamTenantContextV1, sessionId: ArtifactUploadSessionV1['sessionId'], diff --git a/services/api/test/features/iae/artifact-upload.service.test.ts b/services/api/test/features/iae/artifact-upload.service.test.ts index a76016bc..87a5c5d8 100644 --- a/services/api/test/features/iae/artifact-upload.service.test.ts +++ b/services/api/test/features/iae/artifact-upload.service.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; import { ArtifactUploadService } from '../../../src/features/iae/application/artifact-upload.service.js'; import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; +import { InMemoryArtifactUploadStorageAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.js'; const contextResult = createIamTenantContextV1({ actorId: '11111111-1111-4111-8111-111111111111', @@ -20,7 +21,10 @@ if (!contextResult.accepted) throw new Error('fixture context invalid'); const context = contextResult.value; void test('IAE-014 service persists parts and rejects stale completion', async () => { - const service = new ArtifactUploadService(new InMemoryArtifactUploadRepositoryAdapter()); + const service = new ArtifactUploadService( + new InMemoryArtifactUploadRepositoryAdapter(), + new InMemoryArtifactUploadStorageAdapter(), + ); const created = await service.create(context, { sessionId: '55555555-5555-4555-8555-555555555555', artifactId: '66666666-6666-4666-8666-666666666666', @@ -34,7 +38,11 @@ void test('IAE-014 service persists parts and rejects stale completion', async ( }); assert.equal(created.accepted, true); if (!created.accepted) return; + const transfer = await service.issuePartTransfer(context, created.value.sessionId, 1); + assert.equal(transfer.accepted, true); + if (!transfer.accepted) return; const part = await service.recordPart(context, created.value.sessionId, { + transferId: transfer.value.transferId, partNumber: 1, contentSha256: 'b'.repeat(64), byteSize: 4, From 79ebbfde1a6cae73ffe69a46f1aead8f4ffc7c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:38:41 +0700 Subject: [PATCH 54/74] feat(iae): expose opaque upload transfer grants --- services/api/openapi/v1.json | 92 ++++++++++++++++++- .../iae/api/artifact-upload.controller.ts | 22 ++++- .../features/iae/api/artifact-upload.dto.ts | 12 +++ services/api/src/features/iae/iae.module.ts | 11 +++ .../iae/artifact-upload.controller.test.ts | 16 ++++ services/api/test/openapi.test.ts | 1 + 6 files changed, 152 insertions(+), 2 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 39bbec63..31c452dc 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -2412,6 +2412,83 @@ "tags": ["artifacts"] } }, + "/v1/artifact-upload-sessions/{sessionId}/parts/transfer": { + "post": { + "operationId": "ArtifactUploadController.issuePartTransfer", + "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/IssueArtifactUploadTransferDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Issue one opaque upload-part transfer grant", + "tags": ["artifacts"] + } + }, "/v1/artifact-upload-sessions": { "post": { "operationId": "ArtifactUploadController.create", @@ -6575,6 +6652,11 @@ }, "required": ["manifestId", "versionIds", "approvalState", "createdAt"] }, + "IssueArtifactUploadTransferDto": { + "type": "object", + "properties": { "partNumber": { "type": "number", "minimum": 1, "maximum": 1000000 } }, + "required": ["partNumber"] + }, "CreateArtifactUploadSessionDto": { "type": "object", "properties": { @@ -6601,13 +6683,21 @@ "RecordArtifactUploadPartDto": { "type": "object", "properties": { + "transferId": { "type": "string", "format": "uuid" }, "partNumber": { "type": "number", "minimum": 1 }, "contentSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "byteSize": { "type": "number", "minimum": 0 }, "uploadedAt": { "type": "string", "format": "date-time" }, "expectedRevision": { "type": "number", "minimum": 1 } }, - "required": ["partNumber", "contentSha256", "byteSize", "uploadedAt", "expectedRevision"] + "required": [ + "transferId", + "partNumber", + "contentSha256", + "byteSize", + "uploadedAt", + "expectedRevision" + ] }, "CompleteArtifactUploadDto": { "type": "object", diff --git a/services/api/src/features/iae/api/artifact-upload.controller.ts b/services/api/src/features/iae/api/artifact-upload.controller.ts index 6b43131c..95df9092 100644 --- a/services/api/src/features/iae/api/artifact-upload.controller.ts +++ b/services/api/src/features/iae/api/artifact-upload.controller.ts @@ -11,8 +11,13 @@ import { AbortArtifactUploadDto, CompleteArtifactUploadDto, CreateArtifactUploadSessionDto, + IssueArtifactUploadTransferDto, RecordArtifactUploadPartDto, } from './artifact-upload.dto.js'; +import { + ARTIFACT_UPLOAD_STORAGE_PORT, + type ArtifactUploadStoragePortV1, +} from '../application/artifact-upload-storage.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -27,9 +32,24 @@ export class ArtifactUploadController { public constructor( @Inject(ARTIFACT_UPLOAD_REPOSITORY_PORT) repository: ArtifactUploadRepositoryPortV1, + @Inject(ARTIFACT_UPLOAD_STORAGE_PORT) storage: ArtifactUploadStoragePortV1, @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, ) { - this.uploads = new ArtifactUploadService(repository); + this.uploads = new ArtifactUploadService(repository, storage); + } + + @Post(':sessionId/parts/transfer') + @ApiOperation({ summary: 'Issue one opaque upload-part transfer grant' }) + @ApiBody({ type: IssueArtifactUploadTransferDto }) + async issuePartTransfer( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: IssueArtifactUploadTransferDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.issuePartTransfer(context, sessionId.value, input.partNumber); } @Post() diff --git a/services/api/src/features/iae/api/artifact-upload.dto.ts b/services/api/src/features/iae/api/artifact-upload.dto.ts index 6740e832..333eeba6 100644 --- a/services/api/src/features/iae/api/artifact-upload.dto.ts +++ b/services/api/src/features/iae/api/artifact-upload.dto.ts @@ -39,6 +39,10 @@ export class CreateArtifactUploadSessionDto { } export class RecordArtifactUploadPartDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + transferId!: string; + @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) @@ -63,6 +67,14 @@ export class RecordArtifactUploadPartDto { expectedRevision!: number; } +export class IssueArtifactUploadTransferDto { + @ApiProperty({ minimum: 1, maximum: 1000000 }) + @IsInt() + @Min(1) + @Max(1000000) + partNumber!: number; +} + export class CompleteArtifactUploadDto { @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) @Matches(/^[0-9a-f]{64}$/u) diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 54a640ac..b4a84901 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -31,6 +31,7 @@ import { type ArtifactExportDatabaseClientV1, } from './adapter/prisma-artifact-export-repository.adapter.js'; import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-artifact-upload-repository.adapter.js'; +import { InMemoryArtifactUploadStorageAdapter } from './adapter/in-memory-artifact-upload-storage.adapter.js'; import { PrismaArtifactUploadRepositoryAdapter, type ArtifactUploadDatabaseClientV1, @@ -68,6 +69,10 @@ import { ARTIFACT_UPLOAD_REPOSITORY_PORT, type ArtifactUploadRepositoryPortV1, } from './application/artifact-upload-repository.port.js'; +import { + ARTIFACT_UPLOAD_STORAGE_PORT, + type ArtifactUploadStoragePortV1, +} from './application/artifact-upload-storage.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -97,6 +102,7 @@ export interface IaeModuleOptions { readonly artifactUploadRepository?: ArtifactUploadRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; + readonly artifactUploadStorage?: ArtifactUploadStoragePortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly evidenceGrantDatabase?: EvidenceGrantDatabaseClientV1; @@ -168,6 +174,10 @@ export class IaeModule { ? new InMemoryArtifactUploadRepositoryAdapter() : new PrismaArtifactUploadRepositoryAdapter(options.artifactUploadDatabase)), }, + { + provide: ARTIFACT_UPLOAD_STORAGE_PORT, + useValue: options.artifactUploadStorage ?? new InMemoryArtifactUploadStorageAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: @@ -188,6 +198,7 @@ export class IaeModule { ARTIFACT_RETENTION_REPOSITORY_PORT, ARTIFACT_EXPORT_REPOSITORY_PORT, ARTIFACT_UPLOAD_REPOSITORY_PORT, + ARTIFACT_UPLOAD_STORAGE_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-upload.controller.test.ts b/services/api/test/features/iae/artifact-upload.controller.test.ts index b98f9dc5..78ec2f59 100644 --- a/services/api/test/features/iae/artifact-upload.controller.test.ts +++ b/services/api/test/features/iae/artifact-upload.controller.test.ts @@ -45,6 +45,22 @@ void test('IAE-014 upload HTTP control plane never accepts source bytes or paths }); assert.equal(response.statusCode, 201); assert.doesNotMatch(response.body, /sourcePath|localPath|rawBytes|excerpt/iu); + + const transfer = await app.inject({ + method: 'POST', + url: '/v1/artifact-upload-sessions/55555555-5555-4555-8555-555555555555/parts/transfer', + payload: { partNumber: 1 }, + }); + assert.equal(transfer.statusCode, 201); + const transferBody = JSON.parse(transfer.body) as { + accepted: boolean; + value?: { transferId?: string; sessionId?: string; partNumber?: number }; + }; + assert.equal(transferBody.accepted, true); + assert.equal(transferBody.value?.sessionId, '55555555-5555-4555-8555-555555555555'); + assert.equal(transferBody.value?.partNumber, 1); + assert.match(transferBody.value?.transferId ?? '', /^[0-9a-f-]{36}$/u); + assert.doesNotMatch(transfer.body, /url|path|bytes|locator/iu); } finally { await app.close(); } diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index e135b7e3..3f76d4d5 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -69,6 +69,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifact-upload-sessions/{sessionId}/abort', '/v1/artifact-upload-sessions/{sessionId}/complete', '/v1/artifact-upload-sessions/{sessionId}/parts', + '/v1/artifact-upload-sessions/{sessionId}/parts/transfer', '/v1/artifact-versions/{versionId}', '/v1/artifact-versions/{versionId}/admit', '/v1/artifact-versions/{versionId}/deletion-requests', From 83982e8eecd33f19437ad65db958e11a4746b1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:41:10 +0700 Subject: [PATCH 55/74] feat(iae): add secret-free protected document unlock contract --- packages/domain/package.json | 4 + packages/domain/src/protected-document/v1.ts | 202 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../test/protected-document-v1.test.mjs | 58 +++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 270 insertions(+) create mode 100644 packages/domain/src/protected-document/v1.ts create mode 100644 packages/domain/test/protected-document-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 87bbba70..c28c6afe 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -84,6 +84,10 @@ "types": "./src/artifact-upload/v1.ts", "import": "./dist/artifact-upload/v1.js" }, + "./protected-document/v1": { + "types": "./src/protected-document/v1.ts", + "import": "./dist/protected-document/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/protected-document/v1.ts b/packages/domain/src/protected-document/v1.ts new file mode 100644 index 00000000..cfec850b --- /dev/null +++ b/packages/domain/src/protected-document/v1.ts @@ -0,0 +1,202 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAE-015: unlock requests contain state only; secret material never enters this contract. */ +export const PROTECTED_DOCUMENT_SCHEMA_VERSION_V1 = 1 as const; + +export type ProtectedDocumentUnlockModeV1 = 'LOCAL_SECRET_INPUT' | 'DEVICE_KEYCHAIN'; +export type ProtectedDocumentUnlockStateV1 = 'REQUESTED' | 'UNLOCKED' | 'FAILED' | 'EXPIRED'; +export type ProtectedDocumentUnlockFailureCodeV1 = + | 'UNLOCK_REJECTED' + | 'LOCAL_DEVICE_UNAVAILABLE' + | 'UNSUPPORTED_DOCUMENT' + | 'MAX_ATTEMPTS'; + +export interface ProtectedDocumentUnlockRequestV1 { + readonly schemaVersion: typeof PROTECTED_DOCUMENT_SCHEMA_VERSION_V1; + readonly requestId: StableIdentifierV1; + readonly artifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly deviceId?: StableIdentifierV1; + readonly mode: ProtectedDocumentUnlockModeV1; + readonly state: ProtectedDocumentUnlockStateV1; + readonly attemptCount: number; + readonly maxAttempts: number; + readonly lastFailureCode?: ProtectedDocumentUnlockFailureCodeV1; + readonly createdAt: StrictUtcTimestampV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ProtectedDocumentUnlockResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ProtectedDocumentUnlockErrorCodeV1 }; + +export type ProtectedDocumentUnlockErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_MODE' + | 'INVALID_ATTEMPTS' + | 'INVALID_OUTCOME' + | 'INVALID_FAILURE_CODE' + | 'INVALID_STATE' + | 'REVISION_CONFLICT' + | 'EXPIRED' + | 'MAX_ATTEMPTS'; + +const modes = new Set(['LOCAL_SECRET_INPUT', 'DEVICE_KEYCHAIN']); +const failureCodes = new Set([ + 'UNLOCK_REJECTED', + 'LOCAL_DEVICE_UNAVAILABLE', + 'UNSUPPORTED_DOCUMENT', + 'MAX_ATTEMPTS', +]); + +function accepted(value: TValue): ProtectedDocumentUnlockResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected( + code: ProtectedDocumentUnlockErrorCodeV1, +): ProtectedDocumentUnlockResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function mode(input: unknown): ProtectedDocumentUnlockModeV1 | undefined { + return typeof input === 'string' && modes.has(input as ProtectedDocumentUnlockModeV1) + ? (input as ProtectedDocumentUnlockModeV1) + : undefined; +} + +function failureCode(input: unknown): ProtectedDocumentUnlockFailureCodeV1 | undefined { + return typeof input === 'string' && + failureCodes.has(input as ProtectedDocumentUnlockFailureCodeV1) + ? (input as ProtectedDocumentUnlockFailureCodeV1) + : undefined; +} + +function revision(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 ? input : undefined; +} + +function attempts(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 && input <= 10 + ? input + : undefined; +} + +export function createProtectedDocumentUnlockRequestV1(input: { + readonly requestId: unknown; + readonly artifactVersionId: unknown; + readonly tenantScope: unknown; + readonly deviceId?: unknown; + readonly mode: unknown; + readonly maxAttempts?: unknown; + readonly createdAt: unknown; + readonly expiresAt: unknown; +}): ProtectedDocumentUnlockResultV1 { + const requestId = identifier(input.requestId); + const artifactVersionId = identifier(input.artifactVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const deviceId = input.deviceId === undefined ? undefined : identifier(input.deviceId); + const modeValue = mode(input.mode); + const maxAttempts = input.maxAttempts === undefined ? 3 : attempts(input.maxAttempts); + const createdAt = timestamp(input.createdAt); + const expiresAt = timestamp(input.expiresAt); + if (!requestId || !artifactVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (input.deviceId !== undefined && !deviceId) return rejected('INVALID_IDENTIFIER'); + if (!modeValue) return rejected('INVALID_MODE'); + if (!maxAttempts) return rejected('INVALID_ATTEMPTS'); + if (!createdAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); + if (Date.parse(expiresAt) - Date.parse(createdAt) > 24 * 60 * 60 * 1000) + return rejected('INVALID_TIMESTAMP'); + if (modeValue === 'DEVICE_KEYCHAIN' && !deviceId) return rejected('INVALID_IDENTIFIER'); + return accepted( + Object.freeze({ + schemaVersion: PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, + requestId, + artifactVersionId, + tenantScope: tenantScope.value, + ...(deviceId === undefined ? {} : { deviceId }), + mode: modeValue, + state: 'REQUESTED' as const, + attemptCount: 0, + maxAttempts, + createdAt, + expiresAt, + revision: 1, + }), + ); +} + +export function recordProtectedDocumentUnlockResultV1( + request: ProtectedDocumentUnlockRequestV1, + input: { + readonly expectedRevision: unknown; + readonly outcome: unknown; + readonly failureCode?: unknown; + readonly occurredAt: unknown; + }, +): ProtectedDocumentUnlockResultV1 { + if (request.state !== 'REQUESTED') return rejected('INVALID_STATE'); + if (input.expectedRevision !== request.revision) return rejected('REVISION_CONFLICT'); + const occurredAt = timestamp(input.occurredAt); + if (!occurredAt) return rejected('INVALID_TIMESTAMP'); + if (Date.parse(occurredAt) >= Date.parse(request.expiresAt)) return rejected('EXPIRED'); + if (input.outcome !== 'UNLOCKED' && input.outcome !== 'FAILED') + return rejected('INVALID_OUTCOME'); + const nextAttemptCount = request.attemptCount + 1; + if (nextAttemptCount > request.maxAttempts) return rejected('MAX_ATTEMPTS'); + if (input.outcome === 'UNLOCKED') + return accepted( + Object.freeze({ + ...request, + state: 'UNLOCKED' as const, + attemptCount: nextAttemptCount, + revision: request.revision + 1, + }), + ); + const code = input.failureCode === undefined ? 'UNLOCK_REJECTED' : failureCode(input.failureCode); + if (!code) return rejected('INVALID_FAILURE_CODE'); + return accepted( + Object.freeze({ + ...request, + state: nextAttemptCount >= request.maxAttempts ? ('FAILED' as const) : ('REQUESTED' as const), + attemptCount: nextAttemptCount, + lastFailureCode: code, + revision: request.revision + 1, + }), + ); +} + +export function expireProtectedDocumentUnlockRequestV1( + request: ProtectedDocumentUnlockRequestV1, + now: unknown, +): ProtectedDocumentUnlockResultV1 { + const timestampValue = timestamp(now); + if (!timestampValue) return rejected('INVALID_TIMESTAMP'); + if (request.state !== 'REQUESTED') return rejected('INVALID_STATE'); + if (Date.parse(timestampValue) < Date.parse(request.expiresAt)) return rejected('EXPIRED'); + return accepted( + Object.freeze({ ...request, state: 'EXPIRED' as const, revision: request.revision + 1 }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 26dd8ab6..865b9864 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -6,6 +6,7 @@ export * from './artifact-governance/v1.js'; export * from './artifact-retention/v1.js'; export * from './artifact-export/v1.js'; export * from './artifact-upload/v1.js'; +export * from './protected-document/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 85c1613c..d9c679ed 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -11,6 +11,7 @@ const [ artifactRetention, artifactExport, artifactUpload, + protectedDocument, dataset, datasetGovernance, datasetQuality, @@ -38,6 +39,7 @@ const [ import('@databreeze/domain/artifact-retention/v1'), import('@databreeze/domain/artifact-export/v1'), import('@databreeze/domain/artifact-upload/v1'), + import('@databreeze/domain/protected-document/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), @@ -67,6 +69,7 @@ assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); +assert.equal(protectedDocument.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/protected-document-v1.test.mjs b/packages/domain/test/protected-document-v1.test.mjs new file mode 100644 index 00000000..6cde0b01 --- /dev/null +++ b/packages/domain/test/protected-document-v1.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createProtectedDocumentUnlockRequestV1, + expireProtectedDocumentUnlockRequestV1, + recordProtectedDocumentUnlockResultV1, +} from '../dist/protected-document/v1.js'; + +const base = { + requestId: '11111111-1111-4111-8111-111111111111', + artifactVersionId: '22222222-2222-4222-8222-222222222222', + tenantScope: { + scopeType: 'workspace', + organizationId: '33333333-3333-4333-8333-333333333333', + workspaceId: '44444444-4444-4444-8444-444444444444', + }, + deviceId: '55555555-5555-4555-8555-555555555555', + mode: 'DEVICE_KEYCHAIN', + maxAttempts: 2, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T00:30:00.000Z', +}; + +void test('[IAE-015] unlock state never carries credential material and supports bounded retries', () => { + const created = createProtectedDocumentUnlockRequestV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(Object.hasOwn(created.value, 'password'), false); + assert.equal(Object.hasOwn(created.value, 'secret'), false); + const failed = recordProtectedDocumentUnlockResultV1(created.value, { + expectedRevision: 1, + outcome: 'FAILED', + failureCode: 'UNLOCK_REJECTED', + occurredAt: '2026-08-02T00:05:00.000Z', + }); + assert.equal(failed.accepted, true); + if (!failed.accepted) return; + assert.equal(failed.value.state, 'REQUESTED'); + const unlocked = recordProtectedDocumentUnlockResultV1(failed.value, { + expectedRevision: 2, + outcome: 'UNLOCKED', + occurredAt: '2026-08-02T00:06:00.000Z', + }); + assert.equal(unlocked.accepted, true); + if (unlocked.accepted) assert.equal(unlocked.value.state, 'UNLOCKED'); +}); + +void test('[IAE-015] device-keychain requests require a device and expire without a secret', () => { + const missingDevice = createProtectedDocumentUnlockRequestV1({ ...base, deviceId: undefined }); + assert.deepEqual(missingDevice, { accepted: false, code: 'INVALID_IDENTIFIER' }); + const created = createProtectedDocumentUnlockRequestV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const expired = expireProtectedDocumentUnlockRequestV1(created.value, '2026-08-02T00:30:00.000Z'); + assert.equal(expired.accepted, true); + if (expired.accepted) assert.equal(expired.value.state, 'EXPIRED'); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 01dab94f..618e8d28 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -29,6 +29,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-retention/v1', './artifact-export/v1', './artifact-upload/v1', + './protected-document/v1', './dataset/v1', './dataset-governance/v1', './dataset-quality/v1', @@ -72,6 +73,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); assert.equal(aggregate.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.RULE_SET_SCHEMA_VERSION_V1, 1); From b2beaafaa3ea32f78d014819a4a00eac680240c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:44:18 +0700 Subject: [PATCH 56/74] feat(iae): coordinate protected document unlock attempts --- ...protected-document-secret-input.adapter.ts | 75 ++++++++++ ...cted-document-unlock-repository.adapter.ts | 79 +++++++++++ .../protected-document-secret-input.port.ts | 38 +++++ ...otected-document-unlock-repository.port.ts | 23 +++ .../protected-document-unlock.service.ts | 132 ++++++++++++++++++ .../protected-document-unlock.service.test.ts | 58 ++++++++ 6 files changed, 405 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts create mode 100644 services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts create mode 100644 services/api/src/features/iae/application/protected-document-secret-input.port.ts create mode 100644 services/api/src/features/iae/application/protected-document-unlock-repository.port.ts create mode 100644 services/api/src/features/iae/application/protected-document-unlock.service.ts create mode 100644 services/api/test/features/iae/protected-document-unlock.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts b/services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts new file mode 100644 index 00000000..16b92906 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto'; + +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentSecretInputPortV1, + ProtectedDocumentSecretInputResultV1, + ProtectedDocumentUnlockHandleV1, +} from '../application/protected-document-secret-input.port.js'; + +function accepted(value: TValue): ProtectedDocumentSecretInputResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected( + code: Exclude, { readonly accepted: true }>['code'], +): ProtectedDocumentSecretInputResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate); +} + +/** Test/local adapter that models a one-shot OS-secret prompt without storing its value. */ +export class InMemoryProtectedDocumentSecretInputAdapter + implements ProtectedDocumentSecretInputPortV1 +{ + private handles = new Map(); + + public constructor(private readonly now: () => string = () => new Date().toISOString()) {} + + public async issue( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise> { + await Promise.resolve(); + if (!visible(context.tenantScope, request.tenantScope)) return rejected('UNLOCK_SCOPE_DENIED'); + if (request.state !== 'REQUESTED') return rejected('UNLOCK_HANDLE_INVALID'); + if (Date.parse(request.expiresAt) <= Date.parse(this.now())) + return rejected('UNLOCK_HANDLE_EXPIRED'); + const handleId = randomUUID(); + this.handles.set(handleId, { requestId: request.requestId, expiresAt: request.expiresAt }); + return accepted({ handleId, requestId: request.requestId, expiresAt: request.expiresAt }); + } + + public async consume( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + handleId: string, + outcome: 'UNLOCKED' | 'FAILED', + ): Promise> { + await Promise.resolve(); + if (!visible(context.tenantScope, request.tenantScope)) return rejected('UNLOCK_SCOPE_DENIED'); + const handle = this.handles.get(handleId); + if (!handle || handle.requestId !== request.requestId) return rejected('UNLOCK_HANDLE_INVALID'); + this.handles.delete(handleId); + if (Date.parse(handle.expiresAt) <= Date.parse(this.now())) + return rejected('UNLOCK_HANDLE_EXPIRED'); + void outcome; + return accepted(undefined); + } + + public async release( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + await Promise.resolve(); + if (!visible(context.tenantScope, request.tenantScope)) return; + for (const [handleId, handle] of this.handles) + if (handle.requestId === request.requestId) this.handles.delete(handleId); + } +} diff --git a/services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts new file mode 100644 index 00000000..123346fe --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts @@ -0,0 +1,79 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentUnlockRepositoryPortV1, + ProtectedDocumentUnlockTransactionPortV1, +} from '../application/protected-document-unlock-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(request: ProtectedDocumentUnlockRequestV1): ProtectedDocumentUnlockRequestV1 { + return Object.freeze({ + ...request, + tenantScope: Object.freeze({ ...request.tenantScope }), + }); +} + +export class InMemoryProtectedDocumentUnlockRepositoryAdapter + implements ProtectedDocumentUnlockRepositoryPortV1 +{ + private requests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.requests.get(request.requestId); + if (existing && JSON.stringify(existing) === JSON.stringify(request)) return; + if (existing) { + if (request.revision !== existing.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + existing.artifactVersionId !== request.artifactVersionId || + existing.createdAt !== request.createdAt || + JSON.stringify(existing.tenantScope) !== JSON.stringify(request.tenantScope) + ) + throw new Error('IAE_IMMUTABLE_UNLOCK_REQUEST'); + } + this.requests.set(request.requestId, clone(request)); + } + + public async find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise { + await Promise.resolve(); + const request = this.requests.get(requestId); + return request && visible(context.tenantScope, request.tenantScope) + ? clone(request) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ProtectedDocumentUnlockTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.requests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.requests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/application/protected-document-secret-input.port.ts b/services/api/src/features/iae/application/protected-document-secret-input.port.ts new file mode 100644 index 00000000..b7f8579a --- /dev/null +++ b/services/api/src/features/iae/application/protected-document-secret-input.port.ts @@ -0,0 +1,38 @@ +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const PROTECTED_DOCUMENT_SECRET_INPUT_PORT = Symbol('PROTECTED_DOCUMENT_SECRET_INPUT_PORT'); + +export interface ProtectedDocumentUnlockHandleV1 { + readonly handleId: string; + readonly requestId: ProtectedDocumentUnlockRequestV1['requestId']; + readonly expiresAt: ProtectedDocumentUnlockRequestV1['expiresAt']; +} + +export type ProtectedDocumentSecretInputErrorCodeV1 = + | 'UNLOCK_HANDLE_INVALID' + | 'UNLOCK_HANDLE_EXPIRED' + | 'UNLOCK_SCOPE_DENIED'; + +export type ProtectedDocumentSecretInputResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ProtectedDocumentSecretInputErrorCodeV1 }; + +/** + * Local/sidecar boundary for secret entry. The port receives only an opaque + * one-shot handle and an outcome; plaintext credentials never cross the API. + */ +export interface ProtectedDocumentSecretInputPortV1 { + issue( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise>; + consume( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + handleId: string, + outcome: 'UNLOCKED' | 'FAILED', + ): Promise>; + release(context: IamTenantContextV1, request: ProtectedDocumentUnlockRequestV1): Promise; +} diff --git a/services/api/src/features/iae/application/protected-document-unlock-repository.port.ts b/services/api/src/features/iae/application/protected-document-unlock-repository.port.ts new file mode 100644 index 00000000..93f3586a --- /dev/null +++ b/services/api/src/features/iae/application/protected-document-unlock-repository.port.ts @@ -0,0 +1,23 @@ +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT = Symbol( + 'PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT', +); + +export interface ProtectedDocumentUnlockTransactionPortV1 { + save(context: IamTenantContextV1, request: ProtectedDocumentUnlockRequestV1): Promise; + find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise; +} + +export interface ProtectedDocumentUnlockRepositoryPortV1 + extends ProtectedDocumentUnlockTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ProtectedDocumentUnlockTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/protected-document-unlock.service.ts b/services/api/src/features/iae/application/protected-document-unlock.service.ts new file mode 100644 index 00000000..6c0c9454 --- /dev/null +++ b/services/api/src/features/iae/application/protected-document-unlock.service.ts @@ -0,0 +1,132 @@ +import { + createProtectedDocumentUnlockRequestV1, + expireProtectedDocumentUnlockRequestV1, + recordProtectedDocumentUnlockResultV1, + type ProtectedDocumentUnlockRequestV1, + type ProtectedDocumentUnlockResultV1, +} from '@databreeze/domain/protected-document/v1'; +import { parseStableIdentifierV1, tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentSecretInputPortV1, + ProtectedDocumentSecretInputResultV1, + ProtectedDocumentUnlockHandleV1, +} from './protected-document-secret-input.port.js'; +import { InMemoryProtectedDocumentSecretInputAdapter } from '../adapter/in-memory-protected-document-secret-input.adapter.js'; +import type { ProtectedDocumentUnlockRepositoryPortV1 } from './protected-document-unlock-repository.port.js'; + +export type ProtectedDocumentUnlockServiceErrorV1 = + | 'UNLOCK_NOT_FOUND' + | 'UNLOCK_SCOPE_NARROWING_REQUIRED'; +export type ProtectedDocumentUnlockServiceResultV1 = + | ProtectedDocumentUnlockResultV1 + | ProtectedDocumentSecretInputResultV1 + | { readonly accepted: false; readonly code: ProtectedDocumentUnlockServiceErrorV1 }; + +/** Coordinates unlock state while delegating secret entry to a local/sidecar port. */ +export class ProtectedDocumentUnlockService { + public constructor( + private readonly requests: ProtectedDocumentUnlockRepositoryPortV1, + private readonly secretInput: ProtectedDocumentSecretInputPortV1 = new InMemoryProtectedDocumentSecretInputAdapter(), + ) {} + + public async create( + context: IamTenantContextV1, + input: Omit[0], 'tenantScope'> & { + readonly tenantScope?: unknown; + }, + ): Promise> { + const created = createProtectedDocumentUnlockRequestV1({ + ...input, + tenantScope: input.tenantScope ?? context.tenantScope, + }); + if (!created.accepted) return created; + if (!tenantScopeContainsV1(context.tenantScope, created.value.tenantScope)) + return Object.freeze({ accepted: false, code: 'UNLOCK_SCOPE_NARROWING_REQUIRED' as const }); + return this.requests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.requestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('IAE_IMMUTABLE_UNLOCK_REQUEST'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + requestIdInput: unknown, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const request = await this.requests.find(context, requestId.value); + return request + ? Object.freeze({ accepted: true, value: request }) + : Object.freeze({ accepted: false, code: 'UNLOCK_NOT_FOUND' as const }); + } + + public async issueHandle( + context: IamTenantContextV1, + requestIdInput: unknown, + ): Promise> { + const request = await this.find(context, requestIdInput); + if (!request.accepted) return request; + return this.secretInput.issue(context, request.value); + } + + public async recordOutcome( + context: IamTenantContextV1, + requestIdInput: unknown, + input: { + readonly handleId: string; + readonly expectedRevision: unknown; + readonly outcome: unknown; + readonly failureCode?: unknown; + readonly occurredAt: unknown; + }, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + return this.requests.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, requestId.value); + if (!current) return Object.freeze({ accepted: false, code: 'UNLOCK_NOT_FOUND' as const }); + if (input.outcome !== 'UNLOCKED' && input.outcome !== 'FAILED') + return Object.freeze({ accepted: false, code: 'INVALID_OUTCOME' as const }); + const verified = await this.secretInput.consume( + context, + current, + input.handleId, + input.outcome, + ); + if (!verified.accepted) return verified; + const next = recordProtectedDocumentUnlockResultV1(current, input); + if (!next.accepted) return next; + await transaction.save(context, next.value); + return next; + }); + } + + public async expire( + context: IamTenantContextV1, + requestIdInput: unknown, + now: unknown, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + return this.requests.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, requestId.value); + if (!current) return Object.freeze({ accepted: false, code: 'UNLOCK_NOT_FOUND' as const }); + const next = expireProtectedDocumentUnlockRequestV1(current, now); + if (!next.accepted) return next; + await this.secretInput.release(context, current); + await transaction.save(context, next.value); + return next; + }); + } +} diff --git a/services/api/test/features/iae/protected-document-unlock.service.test.ts b/services/api/test/features/iae/protected-document-unlock.service.test.ts new file mode 100644 index 00000000..29b825cc --- /dev/null +++ b/services/api/test/features/iae/protected-document-unlock.service.test.ts @@ -0,0 +1,58 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryProtectedDocumentSecretInputAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.js'; +import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.js'; +import { ProtectedDocumentUnlockService } from '../../../src/features/iae/application/protected-document-unlock.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'protected-document', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const input = { + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T00:20:00.000Z', +}; + +void test('IAE-015 service issues and consumes opaque one-shot unlock handles', async () => { + const service = new ProtectedDocumentUnlockService( + new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + new InMemoryProtectedDocumentSecretInputAdapter(() => '2026-08-02T00:05:00.000Z'), + ); + const created = await service.create(context, input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const handle = await service.issueHandle(context, created.value.requestId); + assert.equal(handle.accepted, true); + if (!handle.accepted) return; + assert.equal(Object.hasOwn(handle.value, 'secret'), false); + const outcome = await service.recordOutcome(context, created.value.requestId, { + handleId: handle.value.handleId, + expectedRevision: 1, + outcome: 'UNLOCKED', + occurredAt: '2026-08-02T00:01:00.000Z', + }); + assert.equal(outcome.accepted, true); + if (outcome.accepted) assert.equal(outcome.value.state, 'UNLOCKED'); + const replay = await service.recordOutcome(context, created.value.requestId, { + handleId: handle.value.handleId, + expectedRevision: 2, + outcome: 'UNLOCKED', + occurredAt: '2026-08-02T00:02:00.000Z', + }); + assert.deepEqual(replay, { accepted: false, code: 'UNLOCK_HANDLE_INVALID' }); +}); From 25863a007c5f3130f2ec72ace5924fd7e2ac664b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:47:42 +0700 Subject: [PATCH 57/74] feat(iae): expose protected document unlock workflow --- services/api/openapi/v1.json | 405 ++++++++++++++++++ .../protected-document-unlock.controller.ts | 90 ++++ .../iae/api/protected-document-unlock.dto.ts | 75 ++++ services/api/src/features/iae/iae.module.ts | 28 ++ ...otected-document-unlock.controller.test.ts | 74 ++++ services/api/test/openapi.test.ts | 5 + 6 files changed, 677 insertions(+) create mode 100644 services/api/src/features/iae/api/protected-document-unlock.controller.ts create mode 100644 services/api/src/features/iae/api/protected-document-unlock.dto.ts create mode 100644 services/api/test/features/iae/protected-document-unlock.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 31c452dc..3900ca28 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -2940,6 +2940,374 @@ "tags": ["artifacts"] } }, + "/v1/protected-document-unlocks": { + "post": { + "operationId": "ProtectedDocumentUnlockController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateProtectedDocumentUnlockDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Create a secret-free protected-document unlock request", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}": { + "get": { + "operationId": "ProtectedDocumentUnlockController.find", + "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read protected-document unlock state without credentials", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}/handle": { + "post": { + "operationId": "ProtectedDocumentUnlockController.issueHandle", + "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Issue a one-shot local secret-input handle", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}/outcome": { + "post": { + "operationId": "ProtectedDocumentUnlockController.recordOutcome", + "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecordProtectedDocumentUnlockOutcomeDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Record a local unlock outcome using an opaque handle", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}/expire": { + "post": { + "operationId": "ProtectedDocumentUnlockController.expire", + "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ExpireProtectedDocumentUnlockDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Expire an open unlock request and release local handles", + "tags": ["artifacts"] + } + }, "/v1/datasets": { "post": { "operationId": "GovernedDatasetController.create", @@ -6730,6 +7098,43 @@ "maxByteSize" ] }, + "CreateProtectedDocumentUnlockDto": { + "type": "object", + "properties": { + "requestId": { "type": "string", "format": "uuid" }, + "artifactVersionId": { "type": "string", "format": "uuid" }, + "mode": { "type": "string", "enum": ["LOCAL_SECRET_INPUT", "DEVICE_KEYCHAIN"] }, + "deviceId": { "type": "string", "format": "uuid" }, + "maxAttempts": { "type": "number", "minimum": 1, "maximum": 10, "default": 3 }, + "createdAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" } + }, + "required": ["requestId", "artifactVersionId", "mode", "createdAt", "expiresAt"] + }, + "RecordProtectedDocumentUnlockOutcomeDto": { + "type": "object", + "properties": { + "handleId": { "type": "string", "format": "uuid" }, + "expectedRevision": { "type": "number", "minimum": 1 }, + "outcome": { "type": "string", "enum": ["UNLOCKED", "FAILED"] }, + "failureCode": { + "type": "string", + "enum": [ + "UNLOCK_REJECTED", + "LOCAL_DEVICE_UNAVAILABLE", + "UNSUPPORTED_DOCUMENT", + "MAX_ATTEMPTS" + ] + }, + "occurredAt": { "type": "string", "format": "date-time" } + }, + "required": ["handleId", "expectedRevision", "outcome", "occurredAt"] + }, + "ExpireProtectedDocumentUnlockDto": { + "type": "object", + "properties": { "now": { "type": "string", "format": "date-time" } }, + "required": ["now"] + }, "GovernedDatasetFieldDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iae/api/protected-document-unlock.controller.ts b/services/api/src/features/iae/api/protected-document-unlock.controller.ts new file mode 100644 index 00000000..9a78f0a7 --- /dev/null +++ b/services/api/src/features/iae/api/protected-document-unlock.controller.ts @@ -0,0 +1,90 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + PROTECTED_DOCUMENT_SECRET_INPUT_PORT, + type ProtectedDocumentSecretInputPortV1, +} from '../application/protected-document-secret-input.port.js'; +import { + PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + type ProtectedDocumentUnlockRepositoryPortV1, +} from '../application/protected-document-unlock-repository.port.js'; +import { ProtectedDocumentUnlockService } from '../application/protected-document-unlock.service.js'; +import { + CreateProtectedDocumentUnlockDto, + ExpireProtectedDocumentUnlockDto, + RecordProtectedDocumentUnlockOutcomeDto, +} from './protected-document-unlock.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-015: unlock control plane exposes state/handles only; secret values stay local. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/protected-document-unlocks') +export class ProtectedDocumentUnlockController { + private readonly unlocks: ProtectedDocumentUnlockService; + + public constructor( + @Inject(PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT) + requests: ProtectedDocumentUnlockRepositoryPortV1, + @Inject(PROTECTED_DOCUMENT_SECRET_INPUT_PORT) secretInput: ProtectedDocumentSecretInputPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.unlocks = new ProtectedDocumentUnlockService(requests, secretInput); + } + + @Post() + @ApiOperation({ summary: 'Create a secret-free protected-document unlock request' }) + @ApiBody({ type: CreateProtectedDocumentUnlockDto }) + async create( + @Req() request: unknown, + @Body() input: CreateProtectedDocumentUnlockDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.create(context, input); + } + + @Get(':requestId') + @ApiOperation({ summary: 'Read protected-document unlock state without credentials' }) + async find(@Req() request: unknown, @Param('requestId') requestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.find(context, requestId); + } + + @Post(':requestId/handle') + @ApiOperation({ summary: 'Issue a one-shot local secret-input handle' }) + async issueHandle( + @Req() request: unknown, + @Param('requestId') requestId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.issueHandle(context, requestId); + } + + @Post(':requestId/outcome') + @ApiOperation({ summary: 'Record a local unlock outcome using an opaque handle' }) + @ApiBody({ type: RecordProtectedDocumentUnlockOutcomeDto }) + async recordOutcome( + @Req() request: unknown, + @Param('requestId') requestId: string, + @Body() input: RecordProtectedDocumentUnlockOutcomeDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.recordOutcome(context, requestId, input); + } + + @Post(':requestId/expire') + @ApiOperation({ summary: 'Expire an open unlock request and release local handles' }) + @ApiBody({ type: ExpireProtectedDocumentUnlockDto }) + async expire( + @Req() request: unknown, + @Param('requestId') requestId: string, + @Body() input: ExpireProtectedDocumentUnlockDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.expire(context, requestId, input.now); + } +} diff --git a/services/api/src/features/iae/api/protected-document-unlock.dto.ts b/services/api/src/features/iae/api/protected-document-unlock.dto.ts new file mode 100644 index 00000000..9cbdd71f --- /dev/null +++ b/services/api/src/features/iae/api/protected-document-unlock.dto.ts @@ -0,0 +1,75 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; + +const unlockModes = ['LOCAL_SECRET_INPUT', 'DEVICE_KEYCHAIN'] as const; +const unlockOutcomes = ['UNLOCKED', 'FAILED'] as const; +const failureCodes = [ + 'UNLOCK_REJECTED', + 'LOCAL_DEVICE_UNAVAILABLE', + 'UNSUPPORTED_DOCUMENT', + 'MAX_ATTEMPTS', +] as const; + +export class CreateProtectedDocumentUnlockDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + requestId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + artifactVersionId!: string; + + @ApiProperty({ enum: unlockModes }) + @IsIn(unlockModes) + mode!: (typeof unlockModes)[number]; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + deviceId?: string; + + @ApiPropertyOptional({ minimum: 1, maximum: 10, default: 3 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + maxAttempts?: number; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + expiresAt!: string; +} + +export class RecordProtectedDocumentUnlockOutcomeDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + handleId!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; + + @ApiProperty({ enum: unlockOutcomes }) + @IsIn(unlockOutcomes) + outcome!: (typeof unlockOutcomes)[number]; + + @ApiPropertyOptional({ enum: failureCodes }) + @IsOptional() + @IsIn(failureCodes) + failureCode?: (typeof failureCodes)[number]; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + occurredAt!: string; +} + +export class ExpireProtectedDocumentUnlockDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + now!: string; +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index b4a84901..8336b45b 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -9,6 +9,7 @@ import { ArtifactRetentionController } from './api/artifact-retention.controller import { ArtifactExportController } from './api/artifact-export.controller.js'; import { ArtifactUploadController } from './api/artifact-upload.controller.js'; import { ArtifactAdmissionController } from './api/artifact-admission.controller.js'; +import { ProtectedDocumentUnlockController } from './api/protected-document-unlock.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -32,6 +33,8 @@ import { } from './adapter/prisma-artifact-export-repository.adapter.js'; import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-artifact-upload-repository.adapter.js'; import { InMemoryArtifactUploadStorageAdapter } from './adapter/in-memory-artifact-upload-storage.adapter.js'; +import { InMemoryProtectedDocumentSecretInputAdapter } from './adapter/in-memory-protected-document-secret-input.adapter.js'; +import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from './adapter/in-memory-protected-document-unlock-repository.adapter.js'; import { PrismaArtifactUploadRepositoryAdapter, type ArtifactUploadDatabaseClientV1, @@ -73,6 +76,14 @@ import { ARTIFACT_UPLOAD_STORAGE_PORT, type ArtifactUploadStoragePortV1, } from './application/artifact-upload-storage.port.js'; +import { + PROTECTED_DOCUMENT_SECRET_INPUT_PORT, + type ProtectedDocumentSecretInputPortV1, +} from './application/protected-document-secret-input.port.js'; +import { + PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + type ProtectedDocumentUnlockRepositoryPortV1, +} from './application/protected-document-unlock-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -103,6 +114,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly artifactUploadStorage?: ArtifactUploadStoragePortV1; + readonly protectedDocumentUnlockRepository?: ProtectedDocumentUnlockRepositoryPortV1; + readonly protectedDocumentSecretInput?: ProtectedDocumentSecretInputPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly evidenceGrantDatabase?: EvidenceGrantDatabaseClientV1; @@ -124,6 +137,7 @@ export class IaeModule { ArtifactExportController, ArtifactUploadController, ArtifactAdmissionController, + ProtectedDocumentUnlockController, ], providers: [ { @@ -178,6 +192,18 @@ export class IaeModule { provide: ARTIFACT_UPLOAD_STORAGE_PORT, useValue: options.artifactUploadStorage ?? new InMemoryArtifactUploadStorageAdapter(), }, + { + provide: PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + useValue: + options.protectedDocumentUnlockRepository ?? + new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + }, + { + provide: PROTECTED_DOCUMENT_SECRET_INPUT_PORT, + useValue: + options.protectedDocumentSecretInput ?? + new InMemoryProtectedDocumentSecretInputAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: @@ -199,6 +225,8 @@ export class IaeModule { ARTIFACT_EXPORT_REPOSITORY_PORT, ARTIFACT_UPLOAD_REPOSITORY_PORT, ARTIFACT_UPLOAD_STORAGE_PORT, + PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + PROTECTED_DOCUMENT_SECRET_INPUT_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/protected-document-unlock.controller.test.ts b/services/api/test/features/iae/protected-document-unlock.controller.test.ts new file mode 100644 index 00000000..58c285b2 --- /dev/null +++ b/services/api/test/features/iae/protected-document-unlock.controller.test.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryProtectedDocumentSecretInputAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.js'; +import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'protected-document-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('IAE-015 HTTP exposes state and handles but rejects secret fields', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + protectedDocumentUnlockRepository: new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + protectedDocumentSecretInput: new InMemoryProtectedDocumentSecretInputAdapter( + () => '2026-08-04T00:05:00.000Z', + ), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/protected-document-unlocks', + payload: { + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-04T00:20:00.000Z', + password: 'must-never-enter-api', + }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, /must-never-enter-api|password/iu); + + const created = await app.inject({ + method: 'POST', + url: '/v1/protected-document-unlocks', + payload: { + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-04T00:20:00.000Z', + }, + }); + assert.equal(created.statusCode, 201); + assert.doesNotMatch(created.body, /password|credential|must-never-enter-api/iu); + + const handle = await app.inject({ + method: 'POST', + url: '/v1/protected-document-unlocks/55555555-5555-4555-8555-555555555555/handle', + }); + assert.equal(handle.statusCode, 201); + assert.doesNotMatch(handle.body, /password|credential|must-never-enter-api/iu); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 3f76d4d5..9f06cd9a 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -130,6 +130,11 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/entitlements/snapshots/{snapshotId}', '/v1/entitlements/usage', '/v1/organizations/{organizationId}/devices', + '/v1/protected-document-unlocks', + '/v1/protected-document-unlocks/{requestId}', + '/v1/protected-document-unlocks/{requestId}/expire', + '/v1/protected-document-unlocks/{requestId}/handle', + '/v1/protected-document-unlocks/{requestId}/outcome', '/v1/reference-entities', '/v1/reference-entities/merge', '/v1/reference-entities/{entityId}/resolutions', From 06272d435c3ea18b16b2630690b8431083e5023d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:50:38 +0700 Subject: [PATCH 58/74] feat(iae): persist protected document unlock state --- .../migration.sql | 27 ++ services/api/prisma/schema/iae.prisma | 25 ++ ...cted-document-unlock-repository.adapter.ts | 240 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 10 +- ...otected-document-unlock-repository.test.ts | 74 ++++++ services/api/test/prisma-foundation.test.mjs | 16 ++ 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql create mode 100644 services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts diff --git a/services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql b/services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql new file mode 100644 index 00000000..590b8cf1 --- /dev/null +++ b/services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql @@ -0,0 +1,27 @@ +-- IAE-015: persist unlock request state only; credentials remain local/ephemeral. +CREATE TABLE "iae"."protected_document_unlock_requests" ( + "id" UUID NOT NULL, + "artifact_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "device_id" UUID, + "mode" VARCHAR(24) NOT NULL, + "state" VARCHAR(16) NOT NULL, + "attempt_count" INTEGER NOT NULL, + "max_attempts" INTEGER NOT NULL, + "last_failure_code" VARCHAR(32), + "created_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "protected_document_unlock_requests_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "protected_document_unlock_artifact_idx" + ON "iae"."protected_document_unlock_requests"("artifact_version_id"); +CREATE INDEX "protected_document_unlock_scope_state_idx" + ON "iae"."protected_document_unlock_requests"("organization_id", "workspace_id", "project_id", "state"); +CREATE INDEX "protected_document_unlock_expiry_idx" + ON "iae"."protected_document_unlock_requests"("expires_at"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 0733a70d..dd254b72 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -195,3 +195,28 @@ model ArtifactUploadSessionRecord { @@map("artifact_upload_sessions") @@schema("iae") } + +/// IAE-015: password-protected document unlock state; no secret material is persisted. +model ProtectedDocumentUnlockRequestRecord { + id String @id @db.Uuid + artifactVersionId String @map("artifact_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + deviceId String? @map("device_id") @db.Uuid + mode String @db.VarChar(24) + state String @db.VarChar(16) + attemptCount Int @map("attempt_count") + maxAttempts Int @map("max_attempts") + lastFailureCode String? @map("last_failure_code") @db.VarChar(32) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([artifactVersionId], map: "protected_document_unlock_artifact_idx") + @@index([organizationId, workspaceId, projectId, state], map: "protected_document_unlock_scope_state_idx") + @@index([expiresAt], map: "protected_document_unlock_expiry_idx") + @@map("protected_document_unlock_requests") + @@schema("iae") +} diff --git a/services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts new file mode 100644 index 00000000..a6e48248 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts @@ -0,0 +1,240 @@ +import { + createProtectedDocumentUnlockRequestV1, + type ProtectedDocumentUnlockRequestV1, +} from '@databreeze/domain/protected-document/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentUnlockRepositoryPortV1, + ProtectedDocumentUnlockTransactionPortV1, +} from '../application/protected-document-unlock-repository.port.js'; + +export interface ProtectedDocumentUnlockDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly deviceId: string | null; + readonly mode: string; + readonly state: string; + readonly attemptCount: number; + readonly maxAttempts: number; + readonly lastFailureCode: string | null; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ProtectedDocumentUnlockDatabaseCreateDataV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly deviceId: string | null; + readonly mode: string; + readonly state: string; + readonly attemptCount: number; + readonly maxAttempts: number; + readonly lastFailureCode: string | null; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ProtectedDocumentUnlockDatabaseClientV1 { + readonly protectedDocumentUnlockRequestRecord: { + create(input: { + readonly data: ProtectedDocumentUnlockDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { + readonly state: string; + readonly attemptCount: number; + readonly lastFailureCode: string | null; + readonly revision: number; + }; + }): Promise; + }; + $transaction( + work: (transaction: ProtectedDocumentUnlockDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: ProtectedDocumentUnlockDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ProtectedDocumentUnlockDatabaseRowV1): ProtectedDocumentUnlockRequestV1 { + const created = createProtectedDocumentUnlockRequestV1({ + requestId: row.id, + artifactVersionId: row.artifactVersionId, + tenantScope: rowScope(row), + ...(row.deviceId === null ? {} : { deviceId: row.deviceId }), + mode: row.mode, + maxAttempts: row.maxAttempts, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + }); + if (!created.accepted) throw new Error('IAE_PERSISTED_UNLOCK_INVALID'); + if ( + !['REQUESTED', 'UNLOCKED', 'FAILED', 'EXPIRED'].includes(row.state) || + !Number.isSafeInteger(row.attemptCount) || + row.attemptCount < 0 || + row.attemptCount > row.maxAttempts || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAE_PERSISTED_UNLOCK_STATE_INVALID'); + const normalized = { + ...created.value, + state: row.state as ProtectedDocumentUnlockRequestV1['state'], + attemptCount: row.attemptCount, + revision: row.revision, + }; + return row.lastFailureCode === null + ? Object.freeze(normalized) + : Object.freeze({ + ...normalized, + lastFailureCode: row.lastFailureCode as NonNullable< + ProtectedDocumentUnlockRequestV1['lastFailureCode'] + >, + }); +} + +function domainToCreate( + request: ProtectedDocumentUnlockRequestV1, +): ProtectedDocumentUnlockDatabaseCreateDataV1 { + return { + ...databaseScope(request.tenantScope), + id: request.requestId, + artifactVersionId: request.artifactVersionId, + deviceId: request.deviceId ?? null, + mode: request.mode, + state: request.state, + attemptCount: request.attemptCount, + maxAttempts: request.maxAttempts, + lastFailureCode: request.lastFailureCode ?? null, + createdAt: new Date(request.createdAt), + expiresAt: new Date(request.expiresAt), + revision: request.revision, + }; +} + +function visible(context: TenantScopeV1, row: ProtectedDocumentUnlockDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaProtectedDocumentUnlockTransactionAdapter + implements ProtectedDocumentUnlockTransactionPortV1 +{ + public constructor(private readonly client: ProtectedDocumentUnlockDatabaseClientV1) {} + + public async save( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.protectedDocumentUnlockRequestRecord.findUnique({ + where: { id: request.requestId }, + }); + if (existing === null) { + await this.client.protectedDocumentUnlockRequestRecord.create({ + data: domainToCreate(request), + }); + return; + } + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(request)) return; + if (request.revision !== current.revision + 1) throw new Error('IAE_UNLOCK_REVISION_CONFLICT'); + if ( + current.artifactVersionId !== request.artifactVersionId || + current.requestId !== request.requestId || + current.mode !== request.mode || + current.deviceId !== request.deviceId || + JSON.stringify(current.tenantScope) !== JSON.stringify(request.tenantScope) + ) + throw new Error('IAE_UNLOCK_IMMUTABLE_IDENTITY'); + await this.client.protectedDocumentUnlockRequestRecord.update({ + where: { id: request.requestId }, + data: { + state: request.state, + attemptCount: request.attemptCount, + lastFailureCode: request.lastFailureCode ?? null, + revision: request.revision, + }, + }); + } + + public async find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise { + const row = await this.client.protectedDocumentUnlockRequestRecord.findUnique({ + where: { id: requestId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } +} + +export class PrismaProtectedDocumentUnlockRepositoryAdapter + implements ProtectedDocumentUnlockRepositoryPortV1 +{ + public constructor(private readonly client: ProtectedDocumentUnlockDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ProtectedDocumentUnlockTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaProtectedDocumentUnlockTransactionAdapter(transaction)), + ); + } + + public save( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + return new PrismaProtectedDocumentUnlockTransactionAdapter(this.client).save(context, request); + } + + public find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise { + return new PrismaProtectedDocumentUnlockTransactionAdapter(this.client).find( + context, + requestId, + ); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 8336b45b..458fc870 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -35,6 +35,10 @@ import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-art import { InMemoryArtifactUploadStorageAdapter } from './adapter/in-memory-artifact-upload-storage.adapter.js'; import { InMemoryProtectedDocumentSecretInputAdapter } from './adapter/in-memory-protected-document-secret-input.adapter.js'; import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from './adapter/in-memory-protected-document-unlock-repository.adapter.js'; +import { + PrismaProtectedDocumentUnlockRepositoryAdapter, + type ProtectedDocumentUnlockDatabaseClientV1, +} from './adapter/prisma-protected-document-unlock-repository.adapter.js'; import { PrismaArtifactUploadRepositoryAdapter, type ArtifactUploadDatabaseClientV1, @@ -115,6 +119,8 @@ export interface IaeModuleOptions { readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly artifactUploadStorage?: ArtifactUploadStoragePortV1; readonly protectedDocumentUnlockRepository?: ProtectedDocumentUnlockRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly protectedDocumentUnlockDatabase?: ProtectedDocumentUnlockDatabaseClientV1; readonly protectedDocumentSecretInput?: ProtectedDocumentSecretInputPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ @@ -196,7 +202,9 @@ export class IaeModule { provide: PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, useValue: options.protectedDocumentUnlockRepository ?? - new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + (options.protectedDocumentUnlockDatabase === undefined + ? new InMemoryProtectedDocumentUnlockRepositoryAdapter() + : new PrismaProtectedDocumentUnlockRepositoryAdapter(options.protectedDocumentUnlockDatabase)), }, { provide: PROTECTED_DOCUMENT_SECRET_INPUT_PORT, diff --git a/services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts b/services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts new file mode 100644 index 00000000..92d8444e --- /dev/null +++ b/services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaProtectedDocumentUnlockRepositoryAdapter, + type ProtectedDocumentUnlockDatabaseClientV1, + type ProtectedDocumentUnlockDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-protected-document', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const created = createProtectedDocumentUnlockRequestV1({ + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-04T00:20:00.000Z', +}); +if (!created.accepted) throw new Error('fixture unlock invalid'); + +function client( + rows: ProtectedDocumentUnlockDatabaseRowV1[], +): ProtectedDocumentUnlockDatabaseClientV1 { + return { + protectedDocumentUnlockRequestRecord: { + create({ data }) { + const row = { ...data } as ProtectedDocumentUnlockDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (!current) throw new Error('fixture unlock not found'); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-015 Prisma unlock adapter persists state without credentials', async () => { + const rows: ProtectedDocumentUnlockDatabaseRowV1[] = []; + const repository = new PrismaProtectedDocumentUnlockRepositoryAdapter(client(rows)); + await repository.save(context, created.value); + const found = await repository.find(context, created.value.requestId); + assert.deepEqual(found, created.value); + assert.equal(rows.length, 1); + const row = rows[0]; + assert.ok(row); + assert.equal(Object.hasOwn(row, 'secret'), false); + assert.equal(Object.hasOwn(row, 'password'), false); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 0acc588d..1a5ad276 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -64,6 +64,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_profiles"/); + assert.match(diff.stdout, /CREATE TABLE "iae"\."protected_document_unlock_requests"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -114,6 +115,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802250000_dsm_quality_results', '20260802260000_iae_inbox_metadata', '20260802270000_dsm_profiles', + '20260802280000_iae_protected_document_unlocks', 'migration_lock.toml', ]); const migration = await readFile( @@ -443,4 +445,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo ]) { assert.match(profileMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } + const protectedDocumentMigration = await readFile( + path.join(migrationsDirectory, inventory[29], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "iae"."protected_document_unlock_requests"', + 'CREATE INDEX "protected_document_unlock_artifact_idx"', + '"last_failure_code" VARCHAR(32)', + ]) { + assert.match( + protectedDocumentMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From f978b19d491bd6b7fd7dd46315f956ffd7ca30bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:52:25 +0700 Subject: [PATCH 59/74] feat(dsm): add governed dataset export manifest contract --- packages/domain/package.json | 4 + packages/domain/src/dataset-export/v1.ts | 212 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../domain/test/dataset-export-v1.test.mjs | 54 +++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 276 insertions(+) create mode 100644 packages/domain/src/dataset-export/v1.ts create mode 100644 packages/domain/test/dataset-export-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index c28c6afe..56ddf114 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -104,6 +104,10 @@ "types": "./src/dataset-profile/v1.ts", "import": "./dist/dataset-profile/v1.js" }, + "./dataset-export/v1": { + "types": "./src/dataset-export/v1.ts", + "import": "./dist/dataset-export/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/dataset-export/v1.ts b/packages/domain/src/dataset-export/v1.ts new file mode 100644 index 00000000..08b55a8a --- /dev/null +++ b/packages/domain/src/dataset-export/v1.ts @@ -0,0 +1,212 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-022: governed-data export verification metadata without raw dataset values. */ +export const DATASET_EXPORT_SCHEMA_VERSION_V1 = 1 as const; + +export type DatasetExportFormatV1 = 'CSV' | 'JSONL' | 'PARQUET' | 'XLSX'; +export type DatasetExportDataModeV1 = 'LOCAL' | 'HYBRID' | 'CLOUD'; +export type DatasetExportPayloadClassV1 = 'GOVERNED_DATA' | 'APPROVED_DERIVED_RESULT'; +export type DatasetExportApprovalStateV1 = 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; +export type DatasetExportQualityStateV1 = 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + +export interface DatasetExportManifestV1 { + readonly schemaVersion: typeof DATASET_EXPORT_SCHEMA_VERSION_V1; + readonly manifestId: StableIdentifierV1; + readonly datasetId: StableIdentifierV1; + readonly datasetVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly dataMode: DatasetExportDataModeV1; + readonly payloadClass: DatasetExportPayloadClassV1; + readonly format: DatasetExportFormatV1; + readonly rowCount: number; + readonly byteSize: number; + readonly contentSha256: string; + readonly schemaVersionId: StableIdentifierV1; + readonly mappingVersionId: StableIdentifierV1; + readonly ruleSetVersionId: StableIdentifierV1; + readonly semanticManifestHash: string; + readonly metricManifestHash: string; + readonly qualityManifestHash: string; + readonly lineageManifestHash: string; + readonly evidenceManifestHash: string; + readonly policyHash: string; + readonly qualityState: DatasetExportQualityStateV1; + readonly approvalState: DatasetExportApprovalStateV1; + readonly createdAt: StrictUtcTimestampV1; +} + +export type DatasetExportErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_MODE' + | 'INVALID_PAYLOAD_CLASS' + | 'INVALID_FORMAT' + | 'INVALID_COUNT' + | 'INVALID_SIZE' + | 'INVALID_HASH' + | 'INVALID_STATE' + | 'INVALID_QUALITY_STATE' + | 'INVALID_TIMESTAMP'; + +export type DatasetExportResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: DatasetExportErrorCodeV1 }; + +const modes = new Set(['LOCAL', 'HYBRID', 'CLOUD']); +const payloadClasses = new Set([ + 'GOVERNED_DATA', + 'APPROVED_DERIVED_RESULT', +]); +const formats = new Set(['CSV', 'JSONL', 'PARQUET', 'XLSX']); +const qualityStates = new Set([ + 'PASS', + 'PASS_WITH_WARNINGS', + 'BLOCKED', + 'INCOMPLETE', +]); +const approvalStates = new Set([ + 'NOT_REQUIRED', + 'PENDING', + 'APPROVED', + 'REJECTED', +]); + +function rejected(code: DatasetExportErrorCodeV1): DatasetExportResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +export function createDatasetExportManifestV1(input: { + readonly manifestId: unknown; + readonly datasetId: unknown; + readonly datasetVersionId: unknown; + readonly tenantScope: unknown; + readonly dataMode: unknown; + readonly payloadClass: unknown; + readonly format: unknown; + readonly rowCount: unknown; + readonly byteSize: unknown; + readonly contentSha256: unknown; + readonly schemaVersionId: unknown; + readonly mappingVersionId: unknown; + readonly ruleSetVersionId: unknown; + readonly semanticManifestHash: unknown; + readonly metricManifestHash: unknown; + readonly qualityManifestHash: unknown; + readonly lineageManifestHash: unknown; + readonly evidenceManifestHash: unknown; + readonly policyHash: unknown; + readonly qualityState: unknown; + readonly approvalState: unknown; + readonly createdAt: unknown; +}): DatasetExportResultV1 { + const manifestId = identifier(input.manifestId); + const datasetId = identifier(input.datasetId); + const datasetVersionId = identifier(input.datasetVersionId); + const schemaVersionId = identifier(input.schemaVersionId); + const mappingVersionId = identifier(input.mappingVersionId); + const ruleSetVersionId = identifier(input.ruleSetVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const dataMode = input.dataMode; + const payloadClass = input.payloadClass; + const format = input.format; + const contentSha256 = hash(input.contentSha256); + const semanticManifestHash = hash(input.semanticManifestHash); + const metricManifestHash = hash(input.metricManifestHash); + const qualityManifestHash = hash(input.qualityManifestHash); + const lineageManifestHash = hash(input.lineageManifestHash); + const evidenceManifestHash = hash(input.evidenceManifestHash); + const policyHash = hash(input.policyHash); + const createdAt = timestamp(input.createdAt); + if ( + !manifestId || + !datasetId || + !datasetVersionId || + !schemaVersionId || + !mappingVersionId || + !ruleSetVersionId + ) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!modes.has(dataMode as DatasetExportDataModeV1)) return rejected('INVALID_MODE'); + if (!payloadClasses.has(payloadClass as DatasetExportPayloadClassV1)) + return rejected('INVALID_PAYLOAD_CLASS'); + if (!formats.has(format as DatasetExportFormatV1)) return rejected('INVALID_FORMAT'); + if ( + typeof input.rowCount !== 'number' || + !Number.isSafeInteger(input.rowCount) || + input.rowCount < 0 + ) + return rejected('INVALID_COUNT'); + if ( + typeof input.byteSize !== 'number' || + !Number.isSafeInteger(input.byteSize) || + input.byteSize < 0 + ) + return rejected('INVALID_SIZE'); + if ( + !contentSha256 || + !semanticManifestHash || + !metricManifestHash || + !qualityManifestHash || + !lineageManifestHash || + !evidenceManifestHash || + !policyHash + ) + return rejected('INVALID_HASH'); + if (!qualityStates.has(input.qualityState as DatasetExportQualityStateV1)) + return rejected('INVALID_QUALITY_STATE'); + if (!approvalStates.has(input.approvalState as DatasetExportApprovalStateV1)) + return rejected('INVALID_STATE'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: DATASET_EXPORT_SCHEMA_VERSION_V1, + manifestId, + datasetId, + datasetVersionId, + tenantScope: tenantScope.value, + dataMode: dataMode as DatasetExportDataModeV1, + payloadClass: payloadClass as DatasetExportPayloadClassV1, + format: format as DatasetExportFormatV1, + rowCount: input.rowCount, + byteSize: input.byteSize, + contentSha256, + schemaVersionId, + mappingVersionId, + ruleSetVersionId, + semanticManifestHash, + metricManifestHash, + qualityManifestHash, + lineageManifestHash, + evidenceManifestHash, + policyHash, + qualityState: input.qualityState as DatasetExportQualityStateV1, + approvalState: input.approvalState as DatasetExportApprovalStateV1, + createdAt, + }), + }); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 865b9864..36575dbc 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -11,6 +11,7 @@ export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; export * from './dataset-profile/v1.js'; +export * from './dataset-export/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index d9c679ed..837fbfd2 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -16,6 +16,7 @@ const [ datasetGovernance, datasetQuality, datasetProfile, + datasetExport, dataMode, jobs, approval, @@ -44,6 +45,7 @@ const [ import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), import('@databreeze/domain/dataset-profile/v1'), + import('@databreeze/domain/dataset-export/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -74,6 +76,7 @@ assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); +assert.equal(datasetExport.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/dataset-export-v1.test.mjs b/packages/domain/test/dataset-export-v1.test.mjs new file mode 100644 index 00000000..c751d6f3 --- /dev/null +++ b/packages/domain/test/dataset-export-v1.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createDatasetExportManifestV1 } from '../dist/dataset-export/v1.js'; + +const base = { + manifestId: '11111111-1111-4111-8111-111111111111', + datasetId: '22222222-2222-4222-8222-222222222222', + datasetVersionId: '33333333-3333-4333-8333-333333333333', + tenantScope: { + scopeType: 'workspace', + organizationId: '44444444-4444-4444-8444-444444444444', + workspaceId: '55555555-5555-4555-8555-555555555555', + }, + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'CSV', + rowCount: 12, + byteSize: 2048, + contentSha256: 'a'.repeat(64), + schemaVersionId: '66666666-6666-4666-8666-666666666666', + mappingVersionId: '77777777-7777-4777-8777-777777777777', + ruleSetVersionId: '88888888-8888-4888-8888-888888888888', + semanticManifestHash: 'b'.repeat(64), + metricManifestHash: 'c'.repeat(64), + qualityManifestHash: 'd'.repeat(64), + lineageManifestHash: 'e'.repeat(64), + evidenceManifestHash: 'f'.repeat(64), + policyHash: '0'.repeat(64), + qualityState: 'PASS', + approvalState: 'APPROVED', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[DSM-022] export manifests bind governance hashes and never contain raw rows', () => { + const created = createDatasetExportManifestV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.rowCount, 12); + assert.equal(Object.hasOwn(created.value, 'rows'), false); + assert.equal(Object.hasOwn(created.value, 'records'), false); + assert.equal(created.value.evidenceManifestHash, 'f'.repeat(64)); +}); + +void test('[DSM-022] exports reject invalid policy hashes and unsupported formats', () => { + assert.deepEqual(createDatasetExportManifestV1({ ...base, format: 'XML' }), { + accepted: false, + code: 'INVALID_FORMAT', + }); + assert.deepEqual(createDatasetExportManifestV1({ ...base, policyHash: 'not-a-hash' }), { + accepted: false, + code: 'INVALID_HASH', + }); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 618e8d28..3121e58f 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -34,6 +34,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './dataset-governance/v1', './dataset-quality/v1', './dataset-profile/v1', + './dataset-export/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -71,6 +72,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); From 66b6e495912929eb6f00dd4c4685381e2b4cc1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:53:51 +0700 Subject: [PATCH 60/74] feat(dsm): coordinate governed export manifests --- ...emory-dataset-export-repository.adapter.ts | 66 +++++++++++++ .../dataset-export-repository.port.ts | 20 ++++ .../dsm/application/dataset-export.service.ts | 70 ++++++++++++++ .../dsm/dataset-export.service.test.ts | 94 +++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/dataset-export-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-export.service.ts create mode 100644 services/api/test/features/dsm/dataset-export.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts new file mode 100644 index 00000000..8a836afc --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts @@ -0,0 +1,66 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetExportManifestV1 } from '@databreeze/domain/dataset-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetExportRepositoryPortV1, + DatasetExportTransactionPortV1, +} from '../application/dataset-export-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(manifest: DatasetExportManifestV1): DatasetExportManifestV1 { + return Object.freeze({ + ...manifest, + tenantScope: Object.freeze({ ...manifest.tenantScope }), + }); +} + +export class InMemoryDatasetExportRepositoryAdapter implements DatasetExportRepositoryPortV1 { + private manifests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.manifests.get(manifest.manifestId); + if (existing && JSON.stringify(existing) !== JSON.stringify(manifest)) + throw new Error('DSM_IMMUTABLE_EXPORT_MANIFEST'); + this.manifests.set(manifest.manifestId, clone(manifest)); + } + + public async find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise { + await Promise.resolve(); + const manifest = this.manifests.get(manifestId); + return manifest && visible(context.tenantScope, manifest.tenantScope) + ? clone(manifest) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetExportTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.manifests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.manifests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/dataset-export-repository.port.ts b/services/api/src/features/dsm/application/dataset-export-repository.port.ts new file mode 100644 index 00000000..1096fb43 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-export-repository.port.ts @@ -0,0 +1,20 @@ +import type { DatasetExportManifestV1 } from '@databreeze/domain/dataset-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_EXPORT_REPOSITORY_PORT = Symbol('DATASET_EXPORT_REPOSITORY_PORT'); + +export interface DatasetExportTransactionPortV1 { + save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise; + find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise; +} + +export interface DatasetExportRepositoryPortV1 extends DatasetExportTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetExportTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-export.service.ts b/services/api/src/features/dsm/application/dataset-export.service.ts new file mode 100644 index 00000000..590249b8 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-export.service.ts @@ -0,0 +1,70 @@ +import { + createDatasetExportManifestV1, + type DatasetExportManifestV1, + type DatasetExportResultV1, +} from '@databreeze/domain/dataset-export/v1'; +import { parseStableIdentifierV1, tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetVersionRepositoryPortV1 } from './dataset-version-repository.port.js'; +import type { DatasetExportRepositoryPortV1 } from './dataset-export-repository.port.js'; + +export type DatasetExportServiceErrorV1 = + | 'DATASET_VERSION_NOT_FOUND' + | 'DATASET_VERSION_MISMATCH' + | 'EXPORT_NOT_FOUND' + | 'EXPORT_SCOPE_NARROWING_REQUIRED'; +export type DatasetExportServiceResultV1 = + | DatasetExportResultV1 + | { readonly accepted: false; readonly code: DatasetExportServiceErrorV1 }; + +/** Binds a governed export manifest to an existing immutable dataset version. */ +export class DatasetExportService { + public constructor( + private readonly manifests: DatasetExportRepositoryPortV1, + private readonly versions: DatasetVersionRepositoryPortV1, + ) {} + + public async create( + context: IamTenantContextV1, + input: Omit[0], 'tenantScope'> & { + readonly tenantScope?: unknown; + }, + ): Promise> { + const created = createDatasetExportManifestV1({ + ...input, + tenantScope: input.tenantScope ?? context.tenantScope, + }); + if (!created.accepted) return created; + if (!tenantScopeContainsV1(context.tenantScope, created.value.tenantScope)) + return Object.freeze({ accepted: false, code: 'EXPORT_SCOPE_NARROWING_REQUIRED' as const }); + const version = await this.versions.find(context, created.value.datasetVersionId); + if (!version) + return Object.freeze({ accepted: false, code: 'DATASET_VERSION_NOT_FOUND' as const }); + if (version.datasetId !== created.value.datasetId) + return Object.freeze({ accepted: false, code: 'DATASET_VERSION_MISMATCH' as const }); + return this.manifests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.manifestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('DSM_IMMUTABLE_EXPORT_MANIFEST'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + manifestIdInput: unknown, + ): Promise> { + const manifestId = parseStableIdentifierV1(manifestIdInput); + if (!manifestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.manifests.find(context, manifestId.value); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'EXPORT_NOT_FOUND' as const }); + } +} diff --git a/services/api/test/features/dsm/dataset-export.service.test.ts b/services/api/test/features/dsm/dataset-export.service.test.ts new file mode 100644 index 00000000..af98659d --- /dev/null +++ b/services/api/test/features/dsm/dataset-export.service.test.ts @@ -0,0 +1,94 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryDatasetExportRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.js'; +import { DatasetExportService } from '../../../src/features/dsm/application/dataset-export.service.js'; +import { DatasetVersionService } from '../../../src/features/dsm/application/dataset-version.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'dataset-export-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const versionId = '55555555-5555-4555-8555-555555555555'; +const datasetId = '66666666-6666-4666-8666-666666666666'; + +void test('[DSM-022] export service requires an existing governed dataset version', async () => { + const versions = new InMemoryDatasetVersionRepositoryAdapter(); + const versionService = new DatasetVersionService(versions); + const registered = await versionService.register(context, { + datasetId, + versionId, + tenantScope: context.tenantScope, + inputArtifactVersionIds: [], + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + engineBuild: 'engine-1', + contentFingerprint: 'a'.repeat(64), + rowCount: 2, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }); + assert.equal(registered.accepted, true); + const service = new DatasetExportService(new InMemoryDatasetExportRepositoryAdapter(), versions); + const missing = await service.create(context, { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId, + datasetVersionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'JSONL', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + }); + assert.deepEqual(missing, { accepted: false, code: 'DATASET_VERSION_NOT_FOUND' }); + const created = await service.create(context, { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId, + datasetVersionId: versionId, + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'JSONL', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + }); + assert.equal(created.accepted, true); +}); From f3d6356ead2ce4783143c8c4c3e227ebb0774f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:56:44 +0700 Subject: [PATCH 61/74] feat(dsm): expose governed dataset export manifests --- services/api/openapi/v1.json | 203 ++++++++++++++++++ .../dsm/api/dataset-export.controller.ts | 50 +++++ .../features/dsm/api/dataset-export.dto.ts | 99 +++++++++ services/api/src/features/dsm/dsm.module.ts | 12 ++ .../dsm/dataset-export.controller.test.ts | 114 ++++++++++ services/api/test/openapi.test.ts | 2 + 6 files changed, 480 insertions(+) create mode 100644 services/api/src/features/dsm/api/dataset-export.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-export.dto.ts create mode 100644 services/api/test/features/dsm/dataset-export.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 3900ca28..7af29559 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -5204,6 +5204,151 @@ "tags": ["datasets"] } }, + "/v1/dataset-exports": { + "post": { + "operationId": "DatasetExportController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDatasetExportManifestDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Create a governed dataset export verification manifest", + "tags": ["datasets"] + } + }, + "/v1/dataset-exports/{manifestId}": { + "get": { + "operationId": "DatasetExportController.find", + "parameters": [ + { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read an immutable governed dataset export manifest", + "tags": ["datasets"] + } + }, "/v1/devices/sync/operations": { "post": { "operationId": "DeviceSyncController.enqueue", @@ -7432,6 +7577,64 @@ "createdAt" ] }, + "CreateDatasetExportManifestDto": { + "type": "object", + "properties": { + "manifestId": { "type": "string", "format": "uuid" }, + "datasetId": { "type": "string", "format": "uuid" }, + "datasetVersionId": { "type": "string", "format": "uuid" }, + "dataMode": { "type": "string", "enum": ["LOCAL", "HYBRID", "CLOUD"] }, + "payloadClass": { + "type": "string", + "enum": ["GOVERNED_DATA", "APPROVED_DERIVED_RESULT"] + }, + "format": { "type": "string", "enum": ["CSV", "JSONL", "PARQUET", "XLSX"] }, + "rowCount": { "type": "number", "minimum": 0 }, + "byteSize": { "type": "number", "minimum": 0 }, + "contentSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "schemaVersionId": { "type": "string", "format": "uuid" }, + "mappingVersionId": { "type": "string", "format": "uuid" }, + "ruleSetVersionId": { "type": "string", "format": "uuid" }, + "semanticManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "metricManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "qualityManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "lineageManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "evidenceManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "policyHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "qualityState": { + "type": "string", + "enum": ["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + }, + "approvalState": { + "type": "string", + "enum": ["NOT_REQUIRED", "PENDING", "APPROVED", "REJECTED"] + }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "manifestId", + "datasetId", + "datasetVersionId", + "dataMode", + "payloadClass", + "format", + "rowCount", + "byteSize", + "contentSha256", + "schemaVersionId", + "mappingVersionId", + "ruleSetVersionId", + "semanticManifestHash", + "metricManifestHash", + "qualityManifestHash", + "lineageManifestHash", + "evidenceManifestHash", + "policyHash", + "qualityState", + "approvalState", + "createdAt" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/src/features/dsm/api/dataset-export.controller.ts b/services/api/src/features/dsm/api/dataset-export.controller.ts new file mode 100644 index 00000000..80116170 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-export.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + DATASET_EXPORT_REPOSITORY_PORT, + type DatasetExportRepositoryPortV1, +} from '../application/dataset-export-repository.port.js'; +import { DatasetExportService } from '../application/dataset-export.service.js'; +import { + DATASET_VERSION_REPOSITORY_PORT, + type DatasetVersionRepositoryPortV1, +} from '../application/dataset-version-repository.port.js'; +import { CreateDatasetExportManifestDto } from './dataset-export.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-exports') +export class DatasetExportController { + private readonly exports: DatasetExportService; + + public constructor( + @Inject(DATASET_EXPORT_REPOSITORY_PORT) manifests: DatasetExportRepositoryPortV1, + @Inject(DATASET_VERSION_REPOSITORY_PORT) versions: DatasetVersionRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.exports = new DatasetExportService(manifests, versions); + } + + @Post() + @ApiOperation({ summary: 'Create a governed dataset export verification manifest' }) + @ApiBody({ type: CreateDatasetExportManifestDto }) + async create( + @Req() request: unknown, + @Body() input: CreateDatasetExportManifestDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.create(context, input); + } + + @Get(':manifestId') + @ApiOperation({ summary: 'Read an immutable governed dataset export manifest' }) + async find(@Req() request: unknown, @Param('manifestId') manifestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.find(context, manifestId); + } +} diff --git a/services/api/src/features/dsm/api/dataset-export.dto.ts b/services/api/src/features/dsm/api/dataset-export.dto.ts new file mode 100644 index 00000000..195a0c12 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-export.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsInt, IsISO8601, IsString, IsUUID, Matches, Max, Min } from 'class-validator'; + +export class CreateDatasetExportManifestDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + manifestId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetVersionId!: string; + + @ApiProperty({ enum: ['LOCAL', 'HYBRID', 'CLOUD'] }) + @IsIn(['LOCAL', 'HYBRID', 'CLOUD']) + dataMode!: 'LOCAL' | 'HYBRID' | 'CLOUD'; + + @ApiProperty({ enum: ['GOVERNED_DATA', 'APPROVED_DERIVED_RESULT'] }) + @IsIn(['GOVERNED_DATA', 'APPROVED_DERIVED_RESULT']) + payloadClass!: 'GOVERNED_DATA' | 'APPROVED_DERIVED_RESULT'; + + @ApiProperty({ enum: ['CSV', 'JSONL', 'PARQUET', 'XLSX'] }) + @IsIn(['CSV', 'JSONL', 'PARQUET', 'XLSX']) + format!: 'CSV' | 'JSONL' | 'PARQUET' | 'XLSX'; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCount!: number; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + byteSize!: number; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + contentSha256!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + schemaVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + mappingVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleSetVersionId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + semanticManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + metricManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + qualityManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + lineageManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + evidenceManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + policyHash!: string; + + @ApiProperty({ enum: ['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'] }) + @IsIn(['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE']) + qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + + @ApiProperty({ enum: ['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED'] }) + @IsIn(['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED']) + approvalState!: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index d44e3da1..50199ce2 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -7,7 +7,9 @@ import { RuleSetController } from './api/rule-set.controller.js'; import { DatasetVersionController } from './api/dataset-version.controller.js'; import { DatasetQualityController } from './api/dataset-quality.controller.js'; import { DatasetProfileController } from './api/dataset-profile.controller.js'; +import { DatasetExportController } from './api/dataset-export.controller.js'; import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; +import { InMemoryDatasetExportRepositoryAdapter } from './adapter/in-memory-dataset-export-repository.adapter.js'; import { PrismaDatasetProfileRepositoryAdapter, type DatasetProfileDatabaseClientV1, @@ -70,6 +72,10 @@ import { DATASET_PROFILE_REPOSITORY_PORT, type DatasetProfileRepositoryPortV1, } from './application/dataset-profile-repository.port.js'; +import { + DATASET_EXPORT_REPOSITORY_PORT, + type DatasetExportRepositoryPortV1, +} from './application/dataset-export-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -98,6 +104,7 @@ export interface DsmModuleOptions { readonly datasetProfileRepository?: DatasetProfileRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetProfileDatabase?: DatasetProfileDatabaseClientV1; + readonly datasetExportRepository?: DatasetExportRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -114,6 +121,7 @@ export class DsmModule { DatasetVersionController, DatasetQualityController, DatasetProfileController, + DatasetExportController, ], providers: [ { @@ -172,6 +180,10 @@ export class DsmModule { ? new InMemoryDatasetProfileRepositoryAdapter() : new PrismaDatasetProfileRepositoryAdapter(options.datasetProfileDatabase)), }, + { + provide: DATASET_EXPORT_REPOSITORY_PORT, + useValue: options.datasetExportRepository ?? new InMemoryDatasetExportRepositoryAdapter(), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/dataset-export.controller.test.ts b/services/api/test/features/dsm/dataset-export.controller.test.ts new file mode 100644 index 00000000..2c7525f5 --- /dev/null +++ b/services/api/test/features/dsm/dataset-export.controller.test.ts @@ -0,0 +1,114 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createDatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetExportRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'dataset-export-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('DSM-022 export endpoint accepts verification metadata and rejects raw rows', async () => { + const versions = new InMemoryDatasetVersionRepositoryAdapter(); + const version = createDatasetVersionManifestV1({ + datasetId: '55555555-5555-4555-8555-555555555555', + versionId: '66666666-6666-4666-8666-666666666666', + tenantScope: tenantContext.tenantScope, + inputArtifactVersionIds: [], + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + engineBuild: 'engine-1', + contentFingerprint: 'a'.repeat(64), + rowCount: 2, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }); + assert.equal(version.accepted, true); + if (!version.accepted) return; + await versions.save(tenantContext, version.value); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetVersionRepository: versions, + datasetExportRepository: new InMemoryDatasetExportRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-exports', + payload: { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId: '55555555-5555-4555-8555-555555555555', + datasetVersionId: '66666666-6666-4666-8666-666666666666', + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'CSV', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + rows: [{ forbidden: 'source value' }], + }, + }); + assert.equal(response.statusCode, 400); + const valid = await app.inject({ + method: 'POST', + url: '/v1/dataset-exports', + payload: { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId: '55555555-5555-4555-8555-555555555555', + datasetVersionId: '66666666-6666-4666-8666-666666666666', + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'CSV', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + }, + }); + assert.equal(valid.statusCode, 201); + assert.doesNotMatch(valid.body, /source value|rows/iu); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 9f06cd9a..51c9ced5 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -94,6 +94,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-exports', + '/v1/dataset-exports/{manifestId}', '/v1/dataset-profiles', '/v1/dataset-profiles/page', '/v1/dataset-profiles/{profileId}', From e396283ab4dda57b51b1b74abf5edf63a76a954c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:58:49 +0700 Subject: [PATCH 62/74] feat(dsm): persist governed export manifests --- .../migration.sql | 35 +++ services/api/prisma/schema/dsm.prisma | 34 +++ ...risma-dataset-export-repository.adapter.ts | 203 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 12 +- .../prisma-dataset-export-repository.test.ts | 78 +++++++ services/api/test/prisma-foundation.test.mjs | 16 ++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-export-repository.test.ts diff --git a/services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql b/services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql new file mode 100644 index 00000000..55b546e6 --- /dev/null +++ b/services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql @@ -0,0 +1,35 @@ +-- DSM-022: persist governed export verification metadata without raw rows. +CREATE TABLE "dsm"."dataset_export_manifests" ( + "id" UUID NOT NULL, + "dataset_id" UUID NOT NULL, + "dataset_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "data_mode" VARCHAR(16) NOT NULL, + "payload_class" VARCHAR(32) NOT NULL, + "format" VARCHAR(16) NOT NULL, + "row_count" BIGINT NOT NULL, + "byte_size" BIGINT NOT NULL, + "content_sha256" CHAR(64) NOT NULL, + "schema_version_id" UUID NOT NULL, + "mapping_version_id" UUID NOT NULL, + "rule_set_version_id" UUID NOT NULL, + "semantic_manifest_hash" CHAR(64) NOT NULL, + "metric_manifest_hash" CHAR(64) NOT NULL, + "quality_manifest_hash" CHAR(64) NOT NULL, + "lineage_manifest_hash" CHAR(64) NOT NULL, + "evidence_manifest_hash" CHAR(64) NOT NULL, + "policy_hash" CHAR(64) NOT NULL, + "quality_state" VARCHAR(24) NOT NULL, + "approval_state" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dataset_export_manifests_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "dataset_export_manifests_dataset_version_idx" + ON "dsm"."dataset_export_manifests"("dataset_version_id"); +CREATE INDEX "dataset_export_manifests_scope_idx" + ON "dsm"."dataset_export_manifests"("organization_id", "workspace_id", "project_id", "dataset_version_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index 082f0833..8078d105 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -98,6 +98,40 @@ model DatasetProfileRecord { @@schema("dsm") } +/// DSM-022: governed export verification metadata; raw rows live only in the approved output. +model DatasetExportManifestRecord { + id String @id @db.Uuid + datasetId String @map("dataset_id") @db.Uuid + datasetVersionId String @map("dataset_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + dataMode String @map("data_mode") @db.VarChar(16) + payloadClass String @map("payload_class") @db.VarChar(32) + format String @db.VarChar(16) + rowCount BigInt @map("row_count") + byteSize BigInt @map("byte_size") + contentSha256 String @map("content_sha256") @db.Char(64) + schemaVersionId String @map("schema_version_id") @db.Uuid + mappingVersionId String @map("mapping_version_id") @db.Uuid + ruleSetVersionId String @map("rule_set_version_id") @db.Uuid + semanticManifestHash String @map("semantic_manifest_hash") @db.Char(64) + metricManifestHash String @map("metric_manifest_hash") @db.Char(64) + qualityManifestHash String @map("quality_manifest_hash") @db.Char(64) + lineageManifestHash String @map("lineage_manifest_hash") @db.Char(64) + evidenceManifestHash String @map("evidence_manifest_hash") @db.Char(64) + policyHash String @map("policy_hash") @db.Char(64) + qualityState String @map("quality_state") @db.VarChar(24) + approvalState String @map("approval_state") @db.VarChar(16) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([datasetVersionId], map: "dataset_export_manifests_dataset_version_idx") + @@index([organizationId, workspaceId, projectId, datasetVersionId], map: "dataset_export_manifests_scope_idx") + @@map("dataset_export_manifests") + @@schema("dsm") +} + /// DSM-025: canonical workspace reference identities are versioned and immutable. model ReferenceEntityVersionRecord { id String @id @db.Uuid diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts new file mode 100644 index 00000000..aaccd5db --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts @@ -0,0 +1,203 @@ +import { + createDatasetExportManifestV1, + type DatasetExportManifestV1, +} from '@databreeze/domain/dataset-export/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetExportRepositoryPortV1, + DatasetExportTransactionPortV1, +} from '../application/dataset-export-repository.port.js'; + +export interface DatasetExportDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly datasetVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly dataMode: string; + readonly payloadClass: string; + readonly format: string; + readonly rowCount: bigint | number; + readonly byteSize: bigint | number; + readonly contentSha256: string; + readonly schemaVersionId: string; + readonly mappingVersionId: string; + readonly ruleSetVersionId: string; + readonly semanticManifestHash: string; + readonly metricManifestHash: string; + readonly qualityManifestHash: string; + readonly lineageManifestHash: string; + readonly evidenceManifestHash: string; + readonly policyHash: string; + readonly qualityState: string; + readonly approvalState: string; + readonly createdAt: Date; +} + +export interface DatasetExportDatabaseCreateDataV1 + extends Omit { + readonly rowCount: bigint; + readonly byteSize: bigint; +} + +export interface DatasetExportDatabaseClientV1 { + readonly datasetExportManifestRecord: { + create(input: { + readonly data: DatasetExportDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetExportDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: DatasetExportDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function safeInteger(input: bigint | number): number { + const value = typeof input === 'bigint' ? Number(input) : input; + if (!Number.isSafeInteger(value) || value < 0) + throw new Error('DSM_PERSISTED_EXPORT_SIZE_INVALID'); + return value; +} + +function rowToDomain(row: DatasetExportDatabaseRowV1): DatasetExportManifestV1 { + const parsed = createDatasetExportManifestV1({ + manifestId: row.id, + datasetId: row.datasetId, + datasetVersionId: row.datasetVersionId, + tenantScope: rowScope(row), + dataMode: row.dataMode, + payloadClass: row.payloadClass, + format: row.format, + rowCount: safeInteger(row.rowCount), + byteSize: safeInteger(row.byteSize), + contentSha256: row.contentSha256, + schemaVersionId: row.schemaVersionId, + mappingVersionId: row.mappingVersionId, + ruleSetVersionId: row.ruleSetVersionId, + semanticManifestHash: row.semanticManifestHash, + metricManifestHash: row.metricManifestHash, + qualityManifestHash: row.qualityManifestHash, + lineageManifestHash: row.lineageManifestHash, + evidenceManifestHash: row.evidenceManifestHash, + policyHash: row.policyHash, + qualityState: row.qualityState, + approvalState: row.approvalState, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_EXPORT_INVALID'); + return parsed.value; +} + +function domainToCreate(manifest: DatasetExportManifestV1): DatasetExportDatabaseCreateDataV1 { + return { + ...databaseScope(manifest.tenantScope), + id: manifest.manifestId, + datasetId: manifest.datasetId, + datasetVersionId: manifest.datasetVersionId, + dataMode: manifest.dataMode, + payloadClass: manifest.payloadClass, + format: manifest.format, + rowCount: BigInt(manifest.rowCount), + byteSize: BigInt(manifest.byteSize), + contentSha256: manifest.contentSha256, + schemaVersionId: manifest.schemaVersionId, + mappingVersionId: manifest.mappingVersionId, + ruleSetVersionId: manifest.ruleSetVersionId, + semanticManifestHash: manifest.semanticManifestHash, + metricManifestHash: manifest.metricManifestHash, + qualityManifestHash: manifest.qualityManifestHash, + lineageManifestHash: manifest.lineageManifestHash, + evidenceManifestHash: manifest.evidenceManifestHash, + policyHash: manifest.policyHash, + qualityState: manifest.qualityState, + approvalState: manifest.approvalState, + createdAt: new Date(manifest.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: DatasetExportDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetExportTransactionAdapter implements DatasetExportTransactionPortV1 { + public constructor(private readonly client: DatasetExportDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetExportManifestRecord.findUnique({ + where: { id: manifest.manifestId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(manifest)) + throw new Error('DSM_IMMUTABLE_EXPORT_MANIFEST'); + return; + } + await this.client.datasetExportManifestRecord.create({ data: domainToCreate(manifest) }); + } + + public async find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise { + const row = await this.client.datasetExportManifestRecord.findUnique({ + where: { id: manifestId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } +} + +export class PrismaDatasetExportRepositoryAdapter implements DatasetExportRepositoryPortV1 { + public constructor(private readonly client: DatasetExportDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetExportTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetExportTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise { + return new PrismaDatasetExportTransactionAdapter(this.client).save(context, manifest); + } + + public find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise { + return new PrismaDatasetExportTransactionAdapter(this.client).find(context, manifestId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 50199ce2..f3694555 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -10,6 +10,10 @@ import { DatasetProfileController } from './api/dataset-profile.controller.js'; import { DatasetExportController } from './api/dataset-export.controller.js'; import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; import { InMemoryDatasetExportRepositoryAdapter } from './adapter/in-memory-dataset-export-repository.adapter.js'; +import { + PrismaDatasetExportRepositoryAdapter, + type DatasetExportDatabaseClientV1, +} from './adapter/prisma-dataset-export-repository.adapter.js'; import { PrismaDatasetProfileRepositoryAdapter, type DatasetProfileDatabaseClientV1, @@ -105,6 +109,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetProfileDatabase?: DatasetProfileDatabaseClientV1; readonly datasetExportRepository?: DatasetExportRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetExportDatabase?: DatasetExportDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -182,7 +188,11 @@ export class DsmModule { }, { provide: DATASET_EXPORT_REPOSITORY_PORT, - useValue: options.datasetExportRepository ?? new InMemoryDatasetExportRepositoryAdapter(), + useValue: + options.datasetExportRepository ?? + (options.datasetExportDatabase === undefined + ? new InMemoryDatasetExportRepositoryAdapter() + : new PrismaDatasetExportRepositoryAdapter(options.datasetExportDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/dsm/prisma-dataset-export-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-export-repository.test.ts new file mode 100644 index 00000000..166099d2 --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-export-repository.test.ts @@ -0,0 +1,78 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createDatasetExportManifestV1 } from '@databreeze/domain/dataset-export/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaDatasetExportRepositoryAdapter, + type DatasetExportDatabaseClientV1, + type DatasetExportDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-export-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-dataset-export', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const manifest = createDatasetExportManifestV1({ + manifestId: '55555555-5555-4555-8555-555555555555', + datasetId: '66666666-6666-4666-8666-666666666666', + datasetVersionId: '77777777-7777-4777-8777-777777777777', + tenantScope: context.tenantScope, + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'JSONL', + rowCount: 2, + byteSize: 100, + contentSha256: 'a'.repeat(64), + schemaVersionId: '88888888-8888-4888-8888-888888888888', + mappingVersionId: '99999999-9999-4999-8999-999999999999', + ruleSetVersionId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + semanticManifestHash: 'b'.repeat(64), + metricManifestHash: 'c'.repeat(64), + qualityManifestHash: 'd'.repeat(64), + lineageManifestHash: 'e'.repeat(64), + evidenceManifestHash: 'f'.repeat(64), + policyHash: '0'.repeat(64), + qualityState: 'PASS', + approvalState: 'APPROVED', + createdAt: '2026-08-04T00:00:00.000Z', +}); +if (!manifest.accepted) throw new Error('fixture export invalid'); + +function client(rows: DatasetExportDatabaseRowV1[]): DatasetExportDatabaseClientV1 { + return { + datasetExportManifestRecord: { + create({ data }) { + const row = { ...data } as DatasetExportDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('DSM-022 Prisma export adapter persists only manifest metadata', async () => { + const rows: DatasetExportDatabaseRowV1[] = []; + const repository = new PrismaDatasetExportRepositoryAdapter(client(rows)); + await repository.save(context, manifest.value); + await repository.save(context, manifest.value); + assert.deepEqual(await repository.find(context, manifest.value.manifestId), manifest.value); + assert.equal(rows.length, 1); + assert.equal(Object.hasOwn(rows[0] as object, 'rows'), false); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 1a5ad276..cfd55f6b 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -65,6 +65,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_profiles"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."protected_document_unlock_requests"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_export_manifests"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -116,6 +117,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802260000_iae_inbox_metadata', '20260802270000_dsm_profiles', '20260802280000_iae_protected_document_unlocks', + '20260802290000_dsm_export_manifests', 'migration_lock.toml', ]); const migration = await readFile( @@ -459,4 +461,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const datasetExportMigration = await readFile( + path.join(migrationsDirectory, inventory[30], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "dsm"."dataset_export_manifests"', + 'CREATE INDEX "dataset_export_manifests_dataset_version_idx"', + '"policy_hash" CHAR(64)', + ]) { + assert.match( + datasetExportMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 27f8a562806e9aa6feb00159126ef34bd1ffcefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:59:53 +0700 Subject: [PATCH 63/74] fix(iae): clean expired upload storage state --- .../application/artifact-upload.service.ts | 12 ++++-- .../iae/artifact-upload.service.test.ts | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts index f4e12e51..5c69f0ad 100644 --- a/services/api/src/features/iae/application/artifact-upload.service.ts +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -112,9 +112,15 @@ export class ArtifactUploadService { sessionId: ArtifactUploadSessionV1['sessionId'], now: unknown, ): Promise> { - return this.mutate(context, sessionId, (session) => - expireArtifactUploadSessionV1(session, now), - ); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = expireArtifactUploadSessionV1(current, now); + if (!next.accepted) return next; + await this.storage.abort(context, current); + await transaction.save(context, next.value); + return next; + }); } public async issuePartTransfer( diff --git a/services/api/test/features/iae/artifact-upload.service.test.ts b/services/api/test/features/iae/artifact-upload.service.test.ts index 87a5c5d8..1340927a 100644 --- a/services/api/test/features/iae/artifact-upload.service.test.ts +++ b/services/api/test/features/iae/artifact-upload.service.test.ts @@ -6,6 +6,17 @@ import { ArtifactUploadService } from '../../../src/features/iae/application/art import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; import { InMemoryArtifactUploadStorageAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.js'; +class TrackingStorageAdapter extends InMemoryArtifactUploadStorageAdapter { + public abortCalls = 0; + + public override async abort( + ...argumentsList: Parameters + ): Promise { + this.abortCalls += 1; + await super.abort(...argumentsList); + } +} + const contextResult = createIamTenantContextV1({ actorId: '11111111-1111-4111-8111-111111111111', tenantScope: { @@ -59,3 +70,32 @@ void test('IAE-014 service persists parts and rejects stale completion', async ( if (!completed.accepted) return; assert.equal(completed.value.state, 'COMPLETED'); }); + +void test('IAE-014 expiration revokes storage-side partial state before persisting terminal status', async () => { + const storage = new TrackingStorageAdapter(); + const service = new ArtifactUploadService(new InMemoryArtifactUploadRepositoryAdapter(), storage); + const created = await service.create(context, { + sessionId: '77777777-7777-4777-8777-777777777777', + artifactId: '88888888-8888-4888-8888-888888888888', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const expired = await service.expire( + context, + created.value.sessionId, + '2026-08-02T01:00:00.000Z', + ); + assert.equal(expired.accepted, true); + if (!expired.accepted) return; + assert.equal(expired.value.state, 'EXPIRED'); + assert.equal(storage.abortCalls, 1); + const transfer = await service.issuePartTransfer(context, created.value.sessionId, 1); + assert.deepEqual(transfer, { accepted: false, code: 'UPLOAD_STORAGE_NOT_READY' }); +}); From 135cac86d3e0a54c001b8d79eb32c054e3e39f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:02:18 +0700 Subject: [PATCH 64/74] feat(sa): add safe deterministic spreadsheet auditor core --- .../databreeze_engine/processors/__init__.py | 3 + .../processors/spreadsheet_auditor.py | 232 ++++++++++++++++++ .../engine/tests/test_spreadsheet_auditor.py | 53 ++++ 3 files changed, 288 insertions(+) create mode 100644 services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py create mode 100644 services/engine/tests/test_spreadsheet_auditor.py diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index 486ff7a1..2825d268 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1 +1,4 @@ """Reviewed built-in processors composed into the closed registry.""" +from .spreadsheet_auditor import SpreadsheetAuditError, SpreadsheetAuditResult, audit_workbook + +__all__ = ["SpreadsheetAuditError", "SpreadsheetAuditResult", "audit_workbook"] diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py new file mode 100644 index 00000000..9c1add53 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -0,0 +1,232 @@ +"""Safe deterministic workbook inventory and formula-family auditing (SA-001..SA-004).""" + +from __future__ import annotations + +import hashlib +import io +import posixpath +import re +import zipfile +from collections import Counter +from collections.abc import Iterator +from typing import Literal +from xml.etree import ElementTree as Xml + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr + +_SHEET_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_CELL_REFERENCE = re.compile(r"^(?P[A-Z]{1,3})(?P[1-9][0-9]*)$", re.IGNORECASE) +_FORMULA_REFERENCE = re.compile(r"\$?[A-Z]{1,3}\$?[1-9][0-9]*", re.IGNORECASE) +_FORMULA_SPACE = re.compile(r"\s+") +_MAX_MEMBERS = 2_048 +_MAX_XML_BYTES = 64 * 1024 * 1024 +_MAX_UNCOMPRESSED_BYTES = 256 * 1024 * 1024 +_MAX_CELLS = 1_000_000 + + +class SpreadsheetSheetSummary(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + name: StrictStr = Field(min_length=1, max_length=128) + maxRow: StrictInt = Field(ge=0) + maxColumn: StrictInt = Field(ge=0) + formulaCount: StrictInt = Field(ge=0) + + +class SpreadsheetFinding(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + sheet: StrictStr = Field(min_length=1, max_length=128) + address: StrictStr = Field(pattern=r"^[A-Z]{1,3}[1-9][0-9]*$") + kind: Literal["FORMULA_FAMILY_OUTLIER", "FORMULA_GAP"] + formulaFingerprint: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +class SpreadsheetAuditResult(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + workbookSha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + sheets: tuple[SpreadsheetSheetSummary, ...] + findings: tuple[SpreadsheetFinding, ...] + blockedReasons: tuple[Literal["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"], ...] + + +class SpreadsheetAuditError(ValueError): + """Stable parser failure without exposing workbook content.""" + + def __init__(self, code: Literal["INVALID_ARCHIVE", "RESOURCE_LIMIT", "MALFORMED_XML"]) -> None: + super().__init__(code) + self.code = code + + +def _safe_member(name: str) -> bool: + if not name or name.startswith("/") or "\\" in name: + return False + normalized = posixpath.normpath(name) + return normalized == name and normalized != "." and not normalized.startswith("../") + + +def _xml(data: bytes) -> Xml.Element: + if len(data) > _MAX_XML_BYTES: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + if b" int: + value = 0 + for character in column.upper(): + value = value * 26 + ord(character) - 64 + return value + + +def _cell_address(reference: str) -> tuple[int, int] | None: + match = _CELL_REFERENCE.fullmatch(reference) + if match is None: + return None + return _column_number(match.group("column")), int(match.group("row")) + + +def _normalized_formula(value: str) -> str: + normalized = _FORMULA_SPACE.sub(" ", value.strip().upper()) + return _FORMULA_REFERENCE.sub("#CELL", normalized) + + +def _fingerprint(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _relationships(root: Xml.Element) -> dict[str, str]: + result: dict[str, str] = {} + for relation in root.findall(f"{{{_REL_NS}}}Relationship"): + relation_id = relation.attrib.get("Id") + target = relation.attrib.get("Target") + if relation_id is None or target is None: + continue + result[relation_id] = target + return result + + +def _sheet_targets(archive: zipfile.ZipFile) -> list[tuple[str, str]]: + workbook = _xml(archive.read("xl/workbook.xml")) + relationships = _relationships(_xml(archive.read("xl/_rels/workbook.xml.rels"))) + sheets: list[tuple[str, str]] = [] + for sheet in workbook.findall(f"{{{_SHEET_NS}}}sheets/{{{_SHEET_NS}}}sheet"): + name = sheet.attrib.get("name") + relation_id = sheet.attrib.get(f"{{{_DOC_REL_NS}}}id") + if name is None or relation_id is None: + raise SpreadsheetAuditError("MALFORMED_XML") + target = relationships.get(relation_id) + if target is None: + raise SpreadsheetAuditError("MALFORMED_XML") + target_path = posixpath.normpath(posixpath.join("xl", target)) + if not target_path.startswith("xl/") or not _safe_member(target_path): + raise SpreadsheetAuditError("INVALID_ARCHIVE") + sheets.append((name, target_path)) + return sheets + + +def _iter_cells(root: Xml.Element) -> Iterator[tuple[str, str | None]]: + for cell in root.iter(f"{{{_SHEET_NS}}}c"): + reference = cell.attrib.get("r") + if reference is None: + continue + formula = cell.find(f"{{{_SHEET_NS}}}f") + yield reference, None if formula is None else "".join(formula.itertext()) + + +def audit_workbook( + content: bytes, + *, + max_uncompressed_bytes: int = _MAX_UNCOMPRESSED_BYTES, + max_cells: int = _MAX_CELLS, +) -> SpreadsheetAuditResult: + """Inventory a workbook and report formula-family anomalies without returning values.""" + if not isinstance(content, bytes) or not content: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + if max_uncompressed_bytes < 1 or max_cells < 1: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + workbook_sha256 = hashlib.sha256(content).hexdigest() + try: + archive = zipfile.ZipFile(io.BytesIO(content)) + except (OSError, zipfile.BadZipFile): + raise SpreadsheetAuditError("INVALID_ARCHIVE") from None + with archive: + infos = archive.infolist() + if len(infos) > _MAX_MEMBERS: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + total_size = 0 + names: set[str] = set() + blocked: set[Literal["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"]] = set() + for info in infos: + if not _safe_member(info.filename) or info.filename in names: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + names.add(info.filename) + total_size += info.file_size + if total_size > max_uncompressed_bytes: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + if info.filename.lower().endswith("vbaproject.bin"): + blocked.add("MACRO") + if info.filename.lower().startswith("xl/externallinks/"): + blocked.add("EXTERNAL_LINK") + if "xl/workbook.xml" not in names or "xl/_rels/workbook.xml.rels" not in names: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + try: + targets = _sheet_targets(archive) + except KeyError: + raise SpreadsheetAuditError("MALFORMED_XML") from None + summaries: list[SpreadsheetSheetSummary] = [] + findings: list[SpreadsheetFinding] = [] + total_cells = 0 + for sheet_name, target in targets: + if target not in names: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + root = _xml(archive.read(target)) + max_row = 0 + max_column = 0 + formulas: list[tuple[str, str]] = [] + for address, formula in _iter_cells(root): + total_cells += 1 + if total_cells > max_cells: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + coordinates = _cell_address(address) + if coordinates is None: + blocked.add("UNSUPPORTED_XML") + continue + column, row = coordinates + max_column = max(max_column, column) + max_row = max(max_row, row) + if formula is not None: + formulas.append((address.upper(), formula)) + families = Counter(_normalized_formula(formula) for _, formula in formulas) + for address, formula in formulas: + family = _normalized_formula(formula) + if families[family] == 1 and len(formulas) >= 3: + findings.append( + SpreadsheetFinding( + sheet=sheet_name, + address=address, + kind="FORMULA_FAMILY_OUTLIER", + formulaFingerprint=_fingerprint(family), + ) + ) + summaries.append( + SpreadsheetSheetSummary( + name=sheet_name, + maxRow=max_row, + maxColumn=max_column, + formulaCount=len(formulas), + ) + ) + return SpreadsheetAuditResult( + workbookSha256=workbook_sha256, + sheets=tuple(summaries), + findings=tuple(findings), + blockedReasons=tuple(sorted(blocked)), + ) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py new file mode 100644 index 00000000..01112531 --- /dev/null +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import io +import zipfile + +import pytest + +from databreeze_engine.processors.spreadsheet_auditor import SpreadsheetAuditError, audit_workbook + + +def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: + workbook = b'''''' + relationships = b'''''' + sheet = b'''SUM(B1:C1)3SUM(B1:C1)3SUM(B1:D1)4''' + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("xl/workbook.xml", workbook) + archive.writestr("xl/_rels/workbook.xml.rels", relationships) + archive.writestr("xl/worksheets/sheet1.xml", sheet) + if macro: + archive.writestr("xl/vbaProject.bin", b"not executed") + if external_link: + archive.writestr("xl/externalLinks/externalLink1.xml", b"") + return output.getvalue() + + +def test_audit_is_value_free_and_reports_formula_family_outlier() -> None: + result = audit_workbook(_workbook()) + assert result.sheets[0].name == "Inventory" + assert result.sheets[0].formulaCount == 3 + assert len(result.findings) == 1 + assert result.findings[0].address == "C1" + assert "SUM(B1:D1)" not in result.model_dump_json() + assert result.blockedReasons == () + + +@pytest.mark.parametrize("flag", ["macro", "external_link"]) +def test_audit_discloses_blocked_execution_features_without_running_them(flag: str) -> None: + result = audit_workbook(_workbook(**{flag: True})) + if flag == "macro": + assert "MACRO" in result.blockedReasons + else: + assert "EXTERNAL_LINK" in result.blockedReasons + + +def test_audit_rejects_archive_traversal_and_cell_resource_exhaustion() -> None: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("../escape.xml", b"bad") + with pytest.raises(SpreadsheetAuditError, match="INVALID_ARCHIVE"): + audit_workbook(output.getvalue()) + with pytest.raises(SpreadsheetAuditError, match="RESOURCE_LIMIT"): + audit_workbook(_workbook(), max_cells=1) From 7a03e215900647d41d2bd6cc40eafb1d04d0ea86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:04:19 +0700 Subject: [PATCH 65/74] feat(sa): add value-free spreadsheet audit result contract --- packages/domain/package.json | 4 + packages/domain/src/spreadsheet-audit/v1.ts | 210 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 2 + .../domain/test/spreadsheet-audit-v1.test.mjs | 56 +++++ 6 files changed, 276 insertions(+) create mode 100644 packages/domain/src/spreadsheet-audit/v1.ts create mode 100644 packages/domain/test/spreadsheet-audit-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 56ddf114..3499f462 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -108,6 +108,10 @@ "types": "./src/dataset-export/v1.ts", "import": "./dist/dataset-export/v1.js" }, + "./spreadsheet-audit/v1": { + "types": "./src/spreadsheet-audit/v1.ts", + "import": "./dist/spreadsheet-audit/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts new file mode 100644 index 00000000..651a14c5 --- /dev/null +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -0,0 +1,210 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** SA-001..SA-006: value-free, exact-version spreadsheet audit results. */ +export const SPREADSHEET_AUDIT_SCHEMA_VERSION_V1 = 1 as const; + +export type SpreadsheetAuditFindingKindV1 = 'FORMULA_FAMILY_OUTLIER' | 'FORMULA_GAP'; +export type SpreadsheetAuditSeverityV1 = 'INFO' | 'WARNING' | 'ERROR'; +export type SpreadsheetAuditBlockedReasonV1 = 'MACRO' | 'EXTERNAL_LINK' | 'UNSUPPORTED_XML'; + +export interface SpreadsheetAuditSheetV1 { + readonly sheetId: StableIdentifierV1; + readonly name: string; + readonly maxRow: number; + readonly maxColumn: number; + readonly formulaCount: number; +} + +export interface SpreadsheetAuditFindingV1 { + readonly findingId: StableIdentifierV1; + readonly sheetId: StableIdentifierV1; + readonly address: string; + readonly kind: SpreadsheetAuditFindingKindV1; + readonly severity: SpreadsheetAuditSeverityV1; + readonly formulaFingerprint: string; +} + +export interface SpreadsheetAuditResultV1 { + readonly schemaVersion: typeof SPREADSHEET_AUDIT_SCHEMA_VERSION_V1; + readonly auditId: StableIdentifierV1; + readonly artifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly workbookSha256: string; + readonly sheets: readonly SpreadsheetAuditSheetV1[]; + readonly findings: readonly SpreadsheetAuditFindingV1[]; + readonly blockedReasons: readonly SpreadsheetAuditBlockedReasonV1[]; + readonly processorVersion: string; + readonly createdAt: StrictUtcTimestampV1; +} + +export type SpreadsheetAuditErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TEXT' + | 'INVALID_HASH' + | 'INVALID_COORDINATE' + | 'INVALID_COUNT' + | 'INVALID_SEVERITY' + | 'INVALID_KIND' + | 'INVALID_BLOCKED_REASON' + | 'DUPLICATE_IDENTIFIER' + | 'DUPLICATE_SHEET' + | 'INVALID_TIMESTAMP'; + +export type SpreadsheetAuditResultValidationV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: SpreadsheetAuditErrorCodeV1 }; + +function rejected(code: SpreadsheetAuditErrorCodeV1): SpreadsheetAuditResultValidationV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function count(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; +} + +function sheet(input: unknown): SpreadsheetAuditSheetV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const sheetId = identifier(record['sheetId']); + const name = text(record['name'], 128); + const maxRow = count(record['maxRow']); + const maxColumn = count(record['maxColumn']); + const formulaCount = count(record['formulaCount']); + if ( + !sheetId || + !name || + maxRow === undefined || + maxColumn === undefined || + formulaCount === undefined + ) + return undefined; + if (maxRow > 1_000_000 || maxColumn > 16_384 || formulaCount > 1_000_000) return undefined; + return Object.freeze({ sheetId, name, maxRow, maxColumn, formulaCount }); +} + +function finding(input: unknown): SpreadsheetAuditFindingV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const findingId = identifier(record['findingId']); + const sheetId = identifier(record['sheetId']); + const address = text(record['address'], 16); + const kind = record['kind']; + const severity = record['severity']; + const formulaFingerprint = hash(record['formulaFingerprint']); + if (!findingId || !sheetId || !address || !/^[A-Z]{1,3}[1-9][0-9]*$/u.test(address.toUpperCase())) + return undefined; + if (kind !== 'FORMULA_FAMILY_OUTLIER' && kind !== 'FORMULA_GAP') return undefined; + if (severity !== 'INFO' && severity !== 'WARNING' && severity !== 'ERROR') return undefined; + if (!formulaFingerprint) return undefined; + return Object.freeze({ + findingId, + sheetId, + address: address.toUpperCase(), + kind: kind as SpreadsheetAuditFindingKindV1, + severity: severity as SpreadsheetAuditSeverityV1, + formulaFingerprint, + }); +} + +export function createSpreadsheetAuditResultV1(input: { + readonly auditId: unknown; + readonly artifactVersionId: unknown; + readonly tenantScope: unknown; + readonly workbookSha256: unknown; + readonly sheets: unknown; + readonly findings: unknown; + readonly blockedReasons: unknown; + readonly processorVersion: unknown; + readonly createdAt: unknown; +}): SpreadsheetAuditResultValidationV1 { + const auditId = identifier(input.auditId); + const artifactVersionId = identifier(input.artifactVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const workbookSha256 = hash(input.workbookSha256); + const processorVersion = text(input.processorVersion, 128); + const createdAt = timestamp(input.createdAt); + if (!auditId || !artifactVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!workbookSha256) return rejected('INVALID_HASH'); + if (!processorVersion) return rejected('INVALID_TEXT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (!Array.isArray(input.sheets) || input.sheets.length === 0 || input.sheets.length > 512) + return rejected('INVALID_COUNT'); + const sheets = input.sheets.map(sheet); + if (sheets.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_COUNT'); + const validSheets = sheets as SpreadsheetAuditSheetV1[]; + if (new Set(validSheets.map((candidate) => candidate.sheetId)).size !== validSheets.length) + return rejected('DUPLICATE_IDENTIFIER'); + if (new Set(validSheets.map((candidate) => candidate.name)).size !== validSheets.length) + return rejected('DUPLICATE_SHEET'); + if (!Array.isArray(input.findings) || input.findings.length > 10_000) + return rejected('INVALID_COUNT'); + const findings = input.findings.map(finding); + if (findings.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_COUNT'); + const validFindings = findings as SpreadsheetAuditFindingV1[]; + if (new Set(validFindings.map((candidate) => candidate.findingId)).size !== validFindings.length) + return rejected('DUPLICATE_IDENTIFIER'); + const sheetIds = new Set(validSheets.map((candidate) => candidate.sheetId)); + if (validFindings.some((candidate) => !sheetIds.has(candidate.sheetId))) + return rejected('INVALID_IDENTIFIER'); + if (!Array.isArray(input.blockedReasons) || input.blockedReasons.length > 3) + return rejected('INVALID_BLOCKED_REASON'); + const blockedReasons = input.blockedReasons; + if ( + blockedReasons.some( + (candidate) => + candidate !== 'MACRO' && candidate !== 'EXTERNAL_LINK' && candidate !== 'UNSUPPORTED_XML', + ) + ) + return rejected('INVALID_BLOCKED_REASON'); + if (new Set(blockedReasons).size !== blockedReasons.length) + return rejected('INVALID_BLOCKED_REASON'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, + auditId, + artifactVersionId, + tenantScope: tenantScope.value, + workbookSha256, + sheets: Object.freeze(validSheets), + findings: Object.freeze(validFindings), + blockedReasons: Object.freeze([...blockedReasons] as SpreadsheetAuditBlockedReasonV1[]), + processorVersion, + createdAt, + }), + }); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 36575dbc..383101b9 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -12,6 +12,7 @@ export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; export * from './dataset-profile/v1.js'; export * from './dataset-export/v1.js'; +export * from './spreadsheet-audit/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 837fbfd2..88cd28a4 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -17,6 +17,7 @@ const [ datasetQuality, datasetProfile, datasetExport, + spreadsheetAudit, dataMode, jobs, approval, @@ -46,6 +47,7 @@ const [ import('@databreeze/domain/dataset-quality/v1'), import('@databreeze/domain/dataset-profile/v1'), import('@databreeze/domain/dataset-export/v1'), + import('@databreeze/domain/spreadsheet-audit/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -77,6 +79,7 @@ assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(datasetExport.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); +assert.equal(spreadsheetAudit.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 3121e58f..49613204 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -35,6 +35,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './dataset-quality/v1', './dataset-profile/v1', './dataset-export/v1', + './spreadsheet-audit/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -73,6 +74,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/spreadsheet-audit-v1.test.mjs b/packages/domain/test/spreadsheet-audit-v1.test.mjs new file mode 100644 index 00000000..4ee400d5 --- /dev/null +++ b/packages/domain/test/spreadsheet-audit-v1.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createSpreadsheetAuditResultV1 } from '../dist/spreadsheet-audit/v1.js'; + +const sheetId = '11111111-1111-4111-8111-111111111111'; +const base = { + auditId: '22222222-2222-4222-8222-222222222222', + artifactVersionId: '33333333-3333-4333-8333-333333333333', + tenantScope: { + scopeType: 'workspace', + organizationId: '44444444-4444-4444-8444-444444444444', + workspaceId: '55555555-5555-4555-8555-555555555555', + }, + workbookSha256: 'a'.repeat(64), + sheets: [{ sheetId, name: 'Inventory', maxRow: 10, maxColumn: 4, formulaCount: 3 }], + findings: [ + { + findingId: '66666666-6666-4666-8666-666666666666', + sheetId, + address: 'c1', + kind: 'FORMULA_FAMILY_OUTLIER', + severity: 'WARNING', + formulaFingerprint: 'b'.repeat(64), + }, + ], + blockedReasons: ['MACRO'], + processorVersion: 'spreadsheet-auditor@1.0.0', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[SA-001, SA-004] audit results retain exact value-free evidence coordinates', () => { + const result = createSpreadsheetAuditResultV1(base); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.findings[0]?.address, 'C1'); + assert.equal(Object.hasOwn(result.value, 'formula'), false); + assert.equal(Object.hasOwn(result.value, 'sourceValue'), false); +}); + +void test('[SA-005] findings cannot reference an unknown sheet or duplicate IDs', () => { + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [{ ...base.findings[0], sheetId: '77777777-7777-4777-8777-777777777777' }], + }), + { accepted: false, code: 'INVALID_IDENTIFIER' }, + ); + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [base.findings[0], base.findings[0]], + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); +}); From 032ba163829aed3dbbd492bc5337f0e2bcea96fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:08:19 +0700 Subject: [PATCH 66/74] feat(sa): coordinate immutable spreadsheet audit results --- ...ry-spreadsheet-audit-repository.adapter.ts | 91 +++++++++++++++++++ .../spreadsheet-audit-repository.port.ts | 24 +++++ .../application/spreadsheet-audit.service.ts | 72 +++++++++++++++ .../sa/spreadsheet-audit.service.test.ts | 90 ++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts create mode 100644 services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts create mode 100644 services/api/src/features/sa/application/spreadsheet-audit.service.ts create mode 100644 services/api/test/features/sa/spreadsheet-audit.service.test.ts diff --git a/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts new file mode 100644 index 00000000..9fb2bb66 --- /dev/null +++ b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts @@ -0,0 +1,91 @@ +import { + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { SpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + SpreadsheetAuditRepositoryPortV1, + SpreadsheetAuditTransactionPortV1, +} from '../application/spreadsheet-audit-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(result: SpreadsheetAuditResultV1): SpreadsheetAuditResultV1 { + return Object.freeze({ + ...result, + tenantScope: Object.freeze({ ...result.tenantScope }), + sheets: Object.freeze(result.sheets.map((sheet) => Object.freeze({ ...sheet }))), + findings: Object.freeze(result.findings.map((finding) => Object.freeze({ ...finding }))), + blockedReasons: Object.freeze([...result.blockedReasons]), + }); +} + +export class InMemorySpreadsheetAuditRepositoryAdapter + implements SpreadsheetAuditRepositoryPortV1 +{ + private results = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('SA_SCOPE_NARROWING_REQUIRED'); + const existing = this.results.get(result.auditId); + if (existing && JSON.stringify(existing) !== JSON.stringify(result)) + throw new Error('SA_IMMUTABLE_AUDIT_RESULT'); + this.results.set(result.auditId, clone(result)); + } + + public async find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise { + await Promise.resolve(); + const result = this.results.get(auditId); + return result && visible(context.tenantScope, result.tenantScope) ? clone(result) : undefined; + } + + public async list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise { + await Promise.resolve(); + return [...this.results.values()] + .filter( + (result) => + result.artifactVersionId === artifactVersionId && + visible(context.tenantScope, result.tenantScope), + ) + .sort((left, right) => left.auditId.localeCompare(right.auditId)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: SpreadsheetAuditTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.results); + try { + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); + } catch (error) { + this.results = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts b/services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts new file mode 100644 index 00000000..c68febb2 --- /dev/null +++ b/services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts @@ -0,0 +1,24 @@ +import type { SpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const SPREADSHEET_AUDIT_REPOSITORY_PORT = Symbol('SPREADSHEET_AUDIT_REPOSITORY_PORT'); + +export interface SpreadsheetAuditTransactionPortV1 { + save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise; + find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise; + list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise; +} + +export interface SpreadsheetAuditRepositoryPortV1 extends SpreadsheetAuditTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: SpreadsheetAuditTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/sa/application/spreadsheet-audit.service.ts b/services/api/src/features/sa/application/spreadsheet-audit.service.ts new file mode 100644 index 00000000..8d4f7e7c --- /dev/null +++ b/services/api/src/features/sa/application/spreadsheet-audit.service.ts @@ -0,0 +1,72 @@ +import { + createSpreadsheetAuditResultV1, + type SpreadsheetAuditResultValidationV1, + type SpreadsheetAuditResultV1, +} from '@databreeze/domain/spreadsheet-audit/v1'; +import { + parseStableIdentifierV1, + tenantScopeContainsV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { SpreadsheetAuditRepositoryPortV1 } from './spreadsheet-audit-repository.port.js'; + +export type SpreadsheetAuditServiceErrorV1 = + | 'AUDIT_NOT_FOUND' + | 'AUDIT_SCOPE_NARROWING_REQUIRED' + | 'INVALID_IDENTIFIER'; + +export type SpreadsheetAuditServiceResultV1 = + | SpreadsheetAuditResultValidationV1 + | { readonly accepted: false; readonly code: SpreadsheetAuditServiceErrorV1 }; + +/** Coordinates immutable, value-free spreadsheet audit results. */ +export class SpreadsheetAuditService { + public constructor(private readonly repository: SpreadsheetAuditRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createSpreadsheetAuditResultV1(input); + if (!created.accepted) return created; + if (!tenantScopeContainsV1(context.tenantScope, created.value.tenantScope)) + return Object.freeze({ accepted: false, code: 'AUDIT_SCOPE_NARROWING_REQUIRED' as const }); + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.auditId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + throw new Error('SA_IMMUTABLE_AUDIT_RESULT'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + auditIdInput: unknown, + ): Promise> { + const auditId = parseStableIdentifierV1(auditIdInput); + if (!auditId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.repository.find(context, auditId.value); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'AUDIT_NOT_FOUND' as const }); + } + + public async list( + context: IamTenantContextV1, + artifactVersionIdInput: unknown, + ): Promise> { + const artifactVersionId = parseStableIdentifierV1(artifactVersionIdInput); + if (!artifactVersionId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + return Object.freeze({ + accepted: true, + value: await this.repository.list(context, artifactVersionId.value), + }); + } +} diff --git a/services/api/test/features/sa/spreadsheet-audit.service.test.ts b/services/api/test/features/sa/spreadsheet-audit.service.test.ts new file mode 100644 index 00000000..1b9aa151 --- /dev/null +++ b/services/api/test/features/sa/spreadsheet-audit.service.test.ts @@ -0,0 +1,90 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemorySpreadsheetAuditRepositoryAdapter } from '../../../src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { SpreadsheetAuditService } from '../../../src/features/sa/application/spreadsheet-audit.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'spreadsheet-audit-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const input = { + auditId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + workbookSha256: 'a'.repeat(64), + sheets: [ + { + sheetId: '77777777-7777-4777-8777-777777777777', + name: 'Orders', + maxRow: 10, + maxColumn: 4, + formulaCount: 2, + }, + ], + findings: [ + { + findingId: '88888888-8888-4888-8888-888888888888', + sheetId: '77777777-7777-4777-8777-777777777777', + address: 'C4', + kind: 'FORMULA_FAMILY_OUTLIER' as const, + severity: 'WARNING' as const, + formulaFingerprint: 'b'.repeat(64), + }, + ], + blockedReasons: [], + processorVersion: 'spreadsheet-auditor-0.1.0', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[SA-001, SA-004] service stores immutable, value-free audit results idempotently', async () => { + const service = new SpreadsheetAuditService(new InMemorySpreadsheetAuditRepositoryAdapter()); + const first = await service.register(context, input); + assert.equal(first.accepted, true); + const second = await service.register(context, input); + assert.deepEqual(second, first); + const listed = await service.list(context, input.artifactVersionId); + assert.equal(listed.accepted, true); + if (listed.accepted) assert.equal(listed.value.length, 1); +}); + +void test('[SA-005] service rejects a result that broadens the authenticated tenant scope', async () => { + const service = new SpreadsheetAuditService(new InMemorySpreadsheetAuditRepositoryAdapter()); + const rejected = await service.register(context, { + ...input, + tenantScope: { + scopeType: 'organization', + organizationId: context.tenantScope.organizationId, + }, + }); + assert.deepEqual(rejected, { accepted: false, code: 'AUDIT_SCOPE_NARROWING_REQUIRED' }); +}); + +void test('[SA-005] service hides results from a different organization', async () => { + const service = new SpreadsheetAuditService(new InMemorySpreadsheetAuditRepositoryAdapter()); + await service.register(context, input); + const otherContextResult = createIamTenantContextV1({ + ...context, + tenantScope: { + scopeType: 'organization', + organizationId: '99999999-9999-4999-8999-999999999999', + }, + idempotencyKey: 'spreadsheet-audit-other-tenant', + }); + if (!otherContextResult.accepted) throw new Error('other context invalid'); + assert.deepEqual(await service.find(otherContextResult.value, input.auditId), { + accepted: false, + code: 'AUDIT_NOT_FOUND', + }); +}); From 3a88d39b34412bab741191a83b28a0d57af0d331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:13:42 +0700 Subject: [PATCH 67/74] feat(sa): expose spreadsheet audit API boundary --- services/api/openapi/v1.json | 275 ++++++++++++++++++ services/api/src/app.module.ts | 5 +- services/api/src/bootstrap.ts | 4 +- .../sa/api/spreadsheet-audit.controller.ts | 55 ++++ .../features/sa/api/spreadsheet-audit.dto.ts | 121 ++++++++ services/api/src/features/sa/sa.module.ts | 39 +++ .../sa/spreadsheet-audit.controller.test.ts | 93 ++++++ services/api/test/openapi.test.ts | 2 + 8 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/sa/api/spreadsheet-audit.controller.ts create mode 100644 services/api/src/features/sa/api/spreadsheet-audit.dto.ts create mode 100644 services/api/src/features/sa/sa.module.ts create mode 100644 services/api/test/features/sa/spreadsheet-audit.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 7af29559..b9ffee49 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6874,6 +6874,223 @@ "summary": "Read the append-only usage ledger state in the caller scope", "tags": ["entitlements"] } + }, + "/v1/spreadsheet-audits": { + "post": { + "operationId": "SpreadsheetAuditController.register", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateSpreadsheetAuditResultDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Register an immutable, value-free spreadsheet audit result", + "tags": ["spreadsheet-audits"] + }, + "get": { + "operationId": "SpreadsheetAuditController.list", + "parameters": [ + { + "name": "artifactVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "List spreadsheet audit results for an exact artifact version", + "tags": ["spreadsheet-audits"] + } + }, + "/v1/spreadsheet-audits/{auditId}": { + "get": { + "operationId": "SpreadsheetAuditController.find", + "parameters": [ + { "name": "auditId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "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" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "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" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "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": [] }], + "summary": "Read an exact immutable spreadsheet audit result", + "tags": ["spreadsheet-audits"] + } } }, "info": { @@ -7939,6 +8156,64 @@ "publishedAt" ] }, + "SpreadsheetAuditSheetDto": { + "type": "object", + "properties": { + "sheetId": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "maxLength": 128 }, + "maxRow": { "type": "number", "minimum": 0, "maximum": 1000000 }, + "maxColumn": { "type": "number", "minimum": 0, "maximum": 16384 }, + "formulaCount": { "type": "number", "minimum": 0, "maximum": 1000000 } + }, + "required": ["sheetId", "name", "maxRow", "maxColumn", "formulaCount"] + }, + "SpreadsheetAuditFindingDto": { + "type": "object", + "properties": { + "findingId": { "type": "string", "format": "uuid" }, + "sheetId": { "type": "string", "format": "uuid" }, + "address": { "type": "string", "pattern": "^[A-Za-z]{1,3}[1-9][0-9]*$" }, + "kind": { "type": "string", "enum": ["FORMULA_FAMILY_OUTLIER", "FORMULA_GAP"] }, + "severity": { "type": "string", "enum": ["INFO", "WARNING", "ERROR"] }, + "formulaFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": ["findingId", "sheetId", "address", "kind", "severity", "formulaFingerprint"] + }, + "CreateSpreadsheetAuditResultDto": { + "type": "object", + "properties": { + "auditId": { "type": "string", "format": "uuid" }, + "artifactVersionId": { "type": "string", "format": "uuid" }, + "workbookSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "sheets": { + "minItems": 1, + "maxItems": 512, + "type": "array", + "items": { "$ref": "#/components/schemas/SpreadsheetAuditSheetDto" } + }, + "findings": { + "maxItems": 10000, + "type": "array", + "items": { "$ref": "#/components/schemas/SpreadsheetAuditFindingDto" } + }, + "blockedReasons": { + "type": "array", + "items": { "type": "string", "enum": ["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"] } + }, + "processorVersion": { "type": "string", "maxLength": 128 }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "auditId", + "artifactVersionId", + "workbookSha256", + "sheets", + "findings", + "blockedReasons", + "processorVersion", + "createdAt" + ] + }, "Identifier": { "title": "Stable UUID Identifier", "description": "An opaque stable UUID identifier.", diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 7a0ee73b..d3b56a14 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -7,6 +7,7 @@ import { DsmModule, type DsmModuleOptions } from './features/dsm/dsm.module.js'; import { DsoModule, type DsoModuleOptions } from './features/dso/dso.module.js'; import { AudModule, type AudModuleOptions } from './features/aud/aud.module.js'; import { BuaModule, type BuaModuleOptions } from './features/bua/bua.module.js'; +import { SaModule, type SaModuleOptions } from './features/sa/sa.module.js'; import { SessionRequestTenantContextAdapter } from './platform/http/session-tenant-context.adapter.js'; export type AppModuleOptions = SystemModuleOptions & @@ -15,7 +16,8 @@ export type AppModuleOptions = SystemModuleOptions & DsmModuleOptions & DsoModuleOptions & AudModuleOptions & - BuaModuleOptions; + BuaModuleOptions & + SaModuleOptions; @Module({}) export class AppModule { @@ -40,6 +42,7 @@ export class AppModule { DsoModule.register(composedOptions), AudModule.register(composedOptions), BuaModule.register(composedOptions), + SaModule.register(composedOptions), ], }; } diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 94eb97d4..66d9dc54 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -9,6 +9,7 @@ import type { IamModuleOptions } from './features/iam/iam.module.js'; import type { IaeModuleOptions } from './features/iae/iae.module.js'; import type { DsmModuleOptions } from './features/dsm/dsm.module.js'; import type { DsoModuleOptions } from './features/dso/dso.module.js'; +import type { SaModuleOptions } from './features/sa/sa.module.js'; import type { ClientCompatibilityPort } from './features/system/application/client-compatibility.port.js'; import type { ReadinessPort } from './features/system/application/readiness.port.js'; import { ProblemDetailsFilter } from './platform/http/problem-details.filter.js'; @@ -28,7 +29,8 @@ export interface ApiApplicationOptions extends IamModuleOptions, IaeModuleOptions, DsmModuleOptions, - DsoModuleOptions { + DsoModuleOptions, + SaModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; readonly requestContext?: RequestContextOptions; diff --git a/services/api/src/features/sa/api/spreadsheet-audit.controller.ts b/services/api/src/features/sa/api/spreadsheet-audit.controller.ts new file mode 100644 index 00000000..c868445a --- /dev/null +++ b/services/api/src/features/sa/api/spreadsheet-audit.controller.ts @@ -0,0 +1,55 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + SPREADSHEET_AUDIT_REPOSITORY_PORT, + type SpreadsheetAuditRepositoryPortV1, +} from '../application/spreadsheet-audit-repository.port.js'; +import { SpreadsheetAuditService } from '../application/spreadsheet-audit.service.js'; +import { CreateSpreadsheetAuditResultDto } from './spreadsheet-audit.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('spreadsheet-audits') +@ApiBearerAuth() +@Controller('v1/spreadsheet-audits') +export class SpreadsheetAuditController { + private readonly audits: SpreadsheetAuditService; + + public constructor( + @Inject(SPREADSHEET_AUDIT_REPOSITORY_PORT) repository: SpreadsheetAuditRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.audits = new SpreadsheetAuditService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable, value-free spreadsheet audit result' }) + @ApiBody({ type: CreateSpreadsheetAuditResultDto }) + async register( + @Req() request: unknown, + @Body() input: CreateSpreadsheetAuditResultDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.audits.register(context, { ...input, tenantScope: context.tenantScope }); + } + + @Get(':auditId') + @ApiOperation({ summary: 'Read an exact immutable spreadsheet audit result' }) + async find(@Req() request: unknown, @Param('auditId') auditId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.audits.find(context, auditId); + } + + @Get() + @ApiOperation({ summary: 'List spreadsheet audit results for an exact artifact version' }) + async list( + @Req() request: unknown, + @Query('artifactVersionId') artifactVersionId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.audits.list(context, artifactVersionId); + } +} diff --git a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts new file mode 100644 index 00000000..c562acba --- /dev/null +++ b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts @@ -0,0 +1,121 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsIn, + IsInt, + IsISO8601, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +const sha256Pattern = '^[0-9a-f]{64}$'; + +export class SpreadsheetAuditSheetDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sheetId!: string; + + @ApiProperty({ maxLength: 128 }) + @IsString() + @MaxLength(128) + name!: string; + + @ApiProperty({ minimum: 0, maximum: 1_000_000 }) + @IsInt() + @Min(0) + @Max(1_000_000) + maxRow!: number; + + @ApiProperty({ minimum: 0, maximum: 16_384 }) + @IsInt() + @Min(0) + @Max(16_384) + maxColumn!: number; + + @ApiProperty({ minimum: 0, maximum: 1_000_000 }) + @IsInt() + @Min(0) + @Max(1_000_000) + formulaCount!: number; +} + +export class SpreadsheetAuditFindingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + findingId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sheetId!: string; + + @ApiProperty({ pattern: '^[A-Za-z]{1,3}[1-9][0-9]*$' }) + @IsString() + @Matches(/^[A-Za-z]{1,3}[1-9][0-9]*$/u) + address!: string; + + @ApiProperty({ enum: ['FORMULA_FAMILY_OUTLIER', 'FORMULA_GAP'] }) + @IsIn(['FORMULA_FAMILY_OUTLIER', 'FORMULA_GAP']) + kind!: 'FORMULA_FAMILY_OUTLIER' | 'FORMULA_GAP'; + + @ApiProperty({ enum: ['INFO', 'WARNING', 'ERROR'] }) + @IsIn(['INFO', 'WARNING', 'ERROR']) + severity!: 'INFO' | 'WARNING' | 'ERROR'; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + formulaFingerprint!: string; +} + +export class CreateSpreadsheetAuditResultDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + auditId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + artifactVersionId!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + workbookSha256!: string; + + @ApiProperty({ type: [SpreadsheetAuditSheetDto], minItems: 1, maxItems: 512 }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(512) + @ValidateNested({ each: true }) + @Type(() => SpreadsheetAuditSheetDto) + sheets!: SpreadsheetAuditSheetDto[]; + + @ApiProperty({ type: [SpreadsheetAuditFindingDto], maxItems: 10_000 }) + @IsArray() + @ArrayMaxSize(10_000) + @ValidateNested({ each: true }) + @Type(() => SpreadsheetAuditFindingDto) + findings!: SpreadsheetAuditFindingDto[]; + + @ApiProperty({ enum: ['MACRO', 'EXTERNAL_LINK', 'UNSUPPORTED_XML'], isArray: true }) + @IsArray() + @ArrayMaxSize(3) + @IsIn(['MACRO', 'EXTERNAL_LINK', 'UNSUPPORTED_XML'], { each: true }) + blockedReasons!: Array<'MACRO' | 'EXTERNAL_LINK' | 'UNSUPPORTED_XML'>; + + @ApiProperty({ maxLength: 128 }) + @IsString() + @MaxLength(128) + processorVersion!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/sa/sa.module.ts b/services/api/src/features/sa/sa.module.ts new file mode 100644 index 00000000..b19b2dd1 --- /dev/null +++ b/services/api/src/features/sa/sa.module.ts @@ -0,0 +1,39 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { SpreadsheetAuditController } from './api/spreadsheet-audit.controller.js'; +import { InMemorySpreadsheetAuditRepositoryAdapter } from './adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { + SPREADSHEET_AUDIT_REPOSITORY_PORT, + type SpreadsheetAuditRepositoryPortV1, +} from './application/spreadsheet-audit-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export interface SaModuleOptions { + readonly spreadsheetAuditRepository?: SpreadsheetAuditRepositoryPortV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class SaModule { + public static register(options: SaModuleOptions = {}): DynamicModule { + return { + module: SaModule, + controllers: [SpreadsheetAuditController], + providers: [ + { + provide: SPREADSHEET_AUDIT_REPOSITORY_PORT, + useValue: options.spreadsheetAuditRepository ?? new InMemorySpreadsheetAuditRepositoryAdapter(), + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [SPREADSHEET_AUDIT_REPOSITORY_PORT], + }; + } +} diff --git a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts new file mode 100644 index 00000000..d3740010 --- /dev/null +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -0,0 +1,93 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemorySpreadsheetAuditRepositoryAdapter } from '../../../src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'spreadsheet-audit-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const payload = { + auditId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + workbookSha256: 'a'.repeat(64), + sheets: [ + { + sheetId: '77777777-7777-4777-8777-777777777777', + name: 'Orders', + maxRow: 10, + maxColumn: 4, + formulaCount: 2, + }, + ], + findings: [ + { + findingId: '88888888-8888-4888-8888-888888888888', + sheetId: '77777777-7777-4777-8777-777777777777', + address: 'c4', + kind: 'FORMULA_FAMILY_OUTLIER', + severity: 'WARNING', + formulaFingerprint: 'b'.repeat(64), + }, + ], + blockedReasons: [], + processorVersion: 'spreadsheet-auditor-0.1.0', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source values', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context), + }; + const { app } = await createApiApplication({ + spreadsheetAuditRepository: new InMemorySpreadsheetAuditRepositoryAdapter(), + requestTenantContext, + }); + try { + const rejected = await app.inject({ + method: 'POST', + url: '/v1/spreadsheet-audits', + payload: { ...payload, formula: '=SUM(A1:A3)', sourceValue: '42' }, + }); + assert.equal(rejected.statusCode, 400); + assert.doesNotMatch(rejected.body, /SUM|42|sourceValue/iu); + + const created = await app.inject({ + method: 'POST', + url: '/v1/spreadsheet-audits', + payload, + }); + assert.equal(created.statusCode, 201); + assert.match(created.body, /"address":"C4"/u); + assert.doesNotMatch(created.body, /sourceValue|source value|SUM\(A1:A3\)/iu); + + const found = await app.inject({ + method: 'GET', + url: `/v1/spreadsheet-audits/${payload.auditId}`, + }); + assert.equal(found.statusCode, 200); + assert.match(found.body, /"auditId":"55555555-5555-4555-8555-555555555555"/u); + + const listed = await app.inject({ + method: 'GET', + url: `/v1/spreadsheet-audits?artifactVersionId=${payload.artifactVersionId}`, + }); + assert.equal(listed.statusCode, 200); + assert.match(listed.body, /"accepted":true/u); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 51c9ced5..143a562b 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -142,6 +142,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/reference-entities/{entityId}/resolutions', '/v1/reference-entities/{entityId}/versions', '/v1/reference-entities/{entityId}/versions/{versionId}', + '/v1/spreadsheet-audits', + '/v1/spreadsheet-audits/{auditId}', '/v1/system/compatibility', '/v1/system/compatibility/check', ]); From 534b51871fa379d0cccf5d04543d224514041a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:16:12 +0700 Subject: [PATCH 68/74] feat(sa): persist spreadsheet audit results --- .../migration.sql | 24 +++ services/api/prisma/schema/platform.prisma | 2 +- services/api/prisma/schema/sa.prisma | 20 ++ ...ma-spreadsheet-audit-repository.adapter.ts | 181 ++++++++++++++++++ services/api/src/features/sa/sa.module.ts | 12 +- .../foundation-module-composition.test.ts | 18 ++ ...risma-spreadsheet-audit-repository.test.ts | 86 +++++++++ services/api/test/prisma-foundation.test.mjs | 18 ++ 8 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql create mode 100644 services/api/prisma/schema/sa.prisma create mode 100644 services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts create mode 100644 services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts diff --git a/services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql b/services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql new file mode 100644 index 00000000..2872f72c --- /dev/null +++ b/services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql @@ -0,0 +1,24 @@ +-- SA-001..SA-006: persist immutable value-free spreadsheet audit metadata. +CREATE SCHEMA IF NOT EXISTS "sa"; + +CREATE TABLE "sa"."spreadsheet_audit_results" ( + "id" UUID NOT NULL, + "artifact_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "workbook_sha256" CHAR(64) NOT NULL, + "sheets" JSONB NOT NULL, + "findings" JSONB NOT NULL, + "blocked_reasons" JSONB NOT NULL, + "processor_version" VARCHAR(128) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "spreadsheet_audit_results_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "spreadsheet_audits_artifact_version_idx" + ON "sa"."spreadsheet_audit_results"("artifact_version_id"); +CREATE INDEX "spreadsheet_audits_scope_idx" + ON "sa"."spreadsheet_audit_results"("organization_id", "workspace_id", "project_id", "artifact_version_id"); diff --git a/services/api/prisma/schema/platform.prisma b/services/api/prisma/schema/platform.prisma index 9ead3c4f..c4f3d74f 100644 --- a/services/api/prisma/schema/platform.prisma +++ b/services/api/prisma/schema/platform.prisma @@ -7,7 +7,7 @@ generator client { datasource db { provider = "postgresql" - schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso"] + schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso", "sa"] } /// Platform-owned registry documenting database-schema ownership boundaries. diff --git a/services/api/prisma/schema/sa.prisma b/services/api/prisma/schema/sa.prisma new file mode 100644 index 00000000..cb7ba19b --- /dev/null +++ b/services/api/prisma/schema/sa.prisma @@ -0,0 +1,20 @@ +/// SA-001..SA-006: immutable, value-free spreadsheet audit results. +model SpreadsheetAuditResultRecord { + id String @id @db.Uuid + artifactVersionId String @map("artifact_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + workbookSha256 String @map("workbook_sha256") @db.Char(64) + sheets Json + findings Json + blockedReasons Json @map("blocked_reasons") + processorVersion String @map("processor_version") @db.VarChar(128) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + + @@index([artifactVersionId], map: "spreadsheet_audits_artifact_version_idx") + @@index([organizationId, workspaceId, projectId, artifactVersionId], map: "spreadsheet_audits_scope_idx") + @@map("spreadsheet_audit_results") + @@schema("sa") +} diff --git a/services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts b/services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts new file mode 100644 index 00000000..f45fe727 --- /dev/null +++ b/services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts @@ -0,0 +1,181 @@ +import { + createSpreadsheetAuditResultV1, + type SpreadsheetAuditResultV1, +} from '@databreeze/domain/spreadsheet-audit/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + SpreadsheetAuditRepositoryPortV1, + SpreadsheetAuditTransactionPortV1, +} from '../application/spreadsheet-audit-repository.port.js'; + +export interface SpreadsheetAuditDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly workbookSha256: string; + readonly sheets: unknown; + readonly findings: unknown; + readonly blockedReasons: unknown; + readonly processorVersion: string; + readonly createdAt: Date; +} + +export interface SpreadsheetAuditDatabaseCreateDataV1 + extends Omit { + readonly createdAt: Date; +} + +export interface SpreadsheetAuditDatabaseClientV1 { + readonly spreadsheetAuditResultRecord: { + create(input: { + readonly data: SpreadsheetAuditDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: SpreadsheetAuditDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: SpreadsheetAuditDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('SA_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: SpreadsheetAuditDatabaseRowV1): SpreadsheetAuditResultV1 { + const parsed = createSpreadsheetAuditResultV1({ + auditId: row.id, + artifactVersionId: row.artifactVersionId, + tenantScope: rowScope(row), + workbookSha256: row.workbookSha256, + sheets: row.sheets, + findings: row.findings, + blockedReasons: row.blockedReasons, + processorVersion: row.processorVersion, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('SA_PERSISTED_AUDIT_RESULT_INVALID'); + return parsed.value; +} + +function domainToCreate(result: SpreadsheetAuditResultV1): SpreadsheetAuditDatabaseCreateDataV1 { + return { + ...databaseScope(result.tenantScope), + id: result.auditId, + artifactVersionId: result.artifactVersionId, + workbookSha256: result.workbookSha256, + sheets: result.sheets, + findings: result.findings, + blockedReasons: result.blockedReasons, + processorVersion: result.processorVersion, + createdAt: new Date(result.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: SpreadsheetAuditDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaSpreadsheetAuditTransactionAdapter implements SpreadsheetAuditTransactionPortV1 { + public constructor(private readonly client: SpreadsheetAuditDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('SA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.spreadsheetAuditResultRecord.findUnique({ + where: { id: result.auditId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(result)) + throw new Error('SA_IMMUTABLE_AUDIT_RESULT'); + return; + } + await this.client.spreadsheetAuditResultRecord.create({ data: domainToCreate(result) }); + } + + public async find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise { + const row = await this.client.spreadsheetAuditResultRecord.findUnique({ + where: { id: auditId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } + + public async list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise { + const rows = await this.client.spreadsheetAuditResultRecord.findMany({ + where: { artifactVersionId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaSpreadsheetAuditRepositoryAdapter implements SpreadsheetAuditRepositoryPortV1 { + public constructor(private readonly client: SpreadsheetAuditDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: SpreadsheetAuditTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaSpreadsheetAuditTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise { + return new PrismaSpreadsheetAuditTransactionAdapter(this.client).save(context, result); + } + + public find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise { + return new PrismaSpreadsheetAuditTransactionAdapter(this.client).find(context, auditId); + } + + public list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise { + return new PrismaSpreadsheetAuditTransactionAdapter(this.client).list( + context, + artifactVersionId, + ); + } +} diff --git a/services/api/src/features/sa/sa.module.ts b/services/api/src/features/sa/sa.module.ts index b19b2dd1..49c5e389 100644 --- a/services/api/src/features/sa/sa.module.ts +++ b/services/api/src/features/sa/sa.module.ts @@ -2,6 +2,10 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { SpreadsheetAuditController } from './api/spreadsheet-audit.controller.js'; import { InMemorySpreadsheetAuditRepositoryAdapter } from './adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { + PrismaSpreadsheetAuditRepositoryAdapter, + type SpreadsheetAuditDatabaseClientV1, +} from './adapter/prisma-spreadsheet-audit-repository.adapter.js'; import { SPREADSHEET_AUDIT_REPOSITORY_PORT, type SpreadsheetAuditRepositoryPortV1, @@ -14,6 +18,8 @@ import { export interface SaModuleOptions { readonly spreadsheetAuditRepository?: SpreadsheetAuditRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly spreadsheetAuditDatabase?: SpreadsheetAuditDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -26,7 +32,11 @@ export class SaModule { providers: [ { provide: SPREADSHEET_AUDIT_REPOSITORY_PORT, - useValue: options.spreadsheetAuditRepository ?? new InMemorySpreadsheetAuditRepositoryAdapter(), + useValue: + options.spreadsheetAuditRepository ?? + (options.spreadsheetAuditDatabase === undefined + ? new InMemorySpreadsheetAuditRepositoryAdapter() + : new PrismaSpreadsheetAuditRepositoryAdapter(options.spreadsheetAuditDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index d644d1bb..760bfe2a 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -25,6 +25,9 @@ import { ENTITLEMENT_REPOSITORY_PORT } from '../../src/features/bua/application/ import { PrismaEntitlementRepositoryAdapter } from '../../src/features/bua/adapter/prisma-entitlement-repository.adapter.js'; import { REQUEST_TENANT_CONTEXT } from '../../src/platform/http/request-tenant-context.port.js'; import { SessionRequestTenantContextAdapter } from '../../src/platform/http/session-tenant-context.adapter.js'; +import { SaModule } from '../../src/features/sa/sa.module.js'; +import { SPREADSHEET_AUDIT_REPOSITORY_PORT } from '../../src/features/sa/application/spreadsheet-audit-repository.port.js'; +import { PrismaSpreadsheetAuditRepositoryAdapter } from '../../src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.js'; function moduleTypes(): readonly unknown[] { const registered = AppModule.register(); @@ -39,6 +42,21 @@ void test('[IAM-001, AUD-001, BUA-001] application composition includes identity const types = moduleTypes(); assert.ok(types.includes(AudModule)); assert.ok(types.includes(BuaModule)); + assert.ok(types.includes(SaModule)); +}); + +void test('[SA-001] configured spreadsheet audit persistence uses the Prisma adapter', () => { + const registered = SaModule.register({ spreadsheetAuditDatabase: {} as never }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === SPREADSHEET_AUDIT_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaSpreadsheetAuditRepositoryAdapter); }); void test('[AUD-001] configured audit persistence uses the Prisma adapter instead of the local fallback', () => { diff --git a/services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts b/services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts new file mode 100644 index 00000000..f7b8caa1 --- /dev/null +++ b/services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts @@ -0,0 +1,86 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaSpreadsheetAuditRepositoryAdapter, + type SpreadsheetAuditDatabaseClientV1, + type SpreadsheetAuditDatabaseRowV1, +} from '../../../src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.js'; +import { createSpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-spreadsheet-audit', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const result = createSpreadsheetAuditResultV1({ + auditId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + workbookSha256: 'a'.repeat(64), + sheets: [ + { + sheetId: '77777777-7777-4777-8777-777777777777', + name: 'Orders', + maxRow: 10, + maxColumn: 4, + formulaCount: 2, + }, + ], + findings: [], + blockedReasons: ['EXTERNAL_LINK'], + processorVersion: 'spreadsheet-auditor-0.1.0', + createdAt: '2026-08-04T00:00:00.000Z', +}); +if (!result.accepted) throw new Error('fixture result invalid'); + +function client(rows: SpreadsheetAuditDatabaseRowV1[]): SpreadsheetAuditDatabaseClientV1 { + return { + spreadsheetAuditResultRecord: { + create({ data }) { + const row = { ...data } as SpreadsheetAuditDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.artifactVersionId === where['artifactVersionId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('SA-001/SA-004 Prisma adapter persists only value-free audit metadata', async () => { + const rows: SpreadsheetAuditDatabaseRowV1[] = []; + const repository = new PrismaSpreadsheetAuditRepositoryAdapter(client(rows)); + await repository.save(context, result.value); + await repository.save(context, result.value); + assert.deepEqual(await repository.find(context, result.value.auditId), result.value); + assert.equal((await repository.list(context, result.value.artifactVersionId)).length, 1); + assert.equal(rows.length, 1); + assert.equal(Object.hasOwn(rows[0] as object, 'formula'), false); + assert.equal(Object.hasOwn(rows[0] as object, 'sourceValue'), false); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index cfd55f6b..40063985 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -51,6 +51,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "dsm"/); assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "jra"/); assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "dso"/); + assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "sa"/); assert.match(diff.stdout, /CREATE TABLE "platform"\."schema_registry"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."users"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_versions"/); @@ -78,6 +79,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dso"\."device_sync_operations"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_sync_conflicts"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."strict_local_package_manifests"/); + assert.match(diff.stdout, /CREATE TABLE "sa"\."spreadsheet_audit_results"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."access_tokens"/); @@ -118,6 +120,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802270000_dsm_profiles', '20260802280000_iae_protected_document_unlocks', '20260802290000_dsm_export_manifests', + '20260802300000_sa_spreadsheet_audits', 'migration_lock.toml', ]); const migration = await readFile( @@ -475,4 +478,19 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const spreadsheetAuditMigration = await readFile( + path.join(migrationsDirectory, inventory[31], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE SCHEMA IF NOT EXISTS "sa"', + 'CREATE TABLE "sa"."spreadsheet_audit_results"', + 'CREATE INDEX "spreadsheet_audits_artifact_version_idx"', + '"blocked_reasons" JSONB', + ]) { + assert.match( + spreadsheetAuditMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From bdd03af56518fb22d7145da3c8315d3160eea74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:17:52 +0700 Subject: [PATCH 69/74] feat(sa): bridge engine audits to value-free manifests --- .../databreeze_engine/processors/__init__.py | 16 ++- .../spreadsheet_auditor_manifest.py | 101 ++++++++++++++++++ .../engine/tests/test_spreadsheet_auditor.py | 46 +++++++- 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index 2825d268..4c676897 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1,4 +1,18 @@ """Reviewed built-in processors composed into the closed registry.""" from .spreadsheet_auditor import SpreadsheetAuditError, SpreadsheetAuditResult, audit_workbook +from .spreadsheet_auditor_manifest import ( + SpreadsheetAuditManifest, + SpreadsheetAuditManifestFinding, + SpreadsheetAuditManifestSheet, + build_spreadsheet_audit_manifest, +) -__all__ = ["SpreadsheetAuditError", "SpreadsheetAuditResult", "audit_workbook"] +__all__ = [ + "SpreadsheetAuditError", + "SpreadsheetAuditManifest", + "SpreadsheetAuditManifestFinding", + "SpreadsheetAuditManifestSheet", + "SpreadsheetAuditResult", + "audit_workbook", + "build_spreadsheet_audit_manifest", +] diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py new file mode 100644 index 00000000..440ce6a1 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py @@ -0,0 +1,101 @@ +"""Map the safe workbook audit into the value-free API manifest (SA-001..SA-006).""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Annotated, Literal + +from databreeze_contracts.v1 import Identifier, TenantScope, UtcTimestamp +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, StringConstraints + +from .spreadsheet_auditor import SpreadsheetAuditResult + +_ProcessorVersion = Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] +_Sha256 = Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + + +class SpreadsheetAuditManifestSheet(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + sheetId: Identifier + name: Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] + maxRow: StrictInt = Field(ge=0, le=1_000_000) + maxColumn: StrictInt = Field(ge=0, le=16_384) + formulaCount: StrictInt = Field(ge=0, le=1_000_000) + + +class SpreadsheetAuditManifestFinding(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + findingId: Identifier + sheetId: Identifier + address: Annotated[StrictStr, StringConstraints(pattern=r"^[A-Z]{1,3}[1-9][0-9]*$")] + kind: Literal["FORMULA_FAMILY_OUTLIER", "FORMULA_GAP"] + severity: Literal["INFO", "WARNING", "ERROR"] + formulaFingerprint: _Sha256 + + +class SpreadsheetAuditManifest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + schemaVersion: Literal[1] + auditId: Identifier + artifactVersionId: Identifier + tenantScope: TenantScope + workbookSha256: _Sha256 + sheets: tuple[SpreadsheetAuditManifestSheet, ...] + findings: tuple[SpreadsheetAuditManifestFinding, ...] + blockedReasons: tuple[Literal["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"], ...] + processorVersion: _ProcessorVersion + createdAt: UtcTimestamp + + +def build_spreadsheet_audit_manifest( + result: SpreadsheetAuditResult, + *, + audit_id: str, + artifact_version_id: str, + tenant_scope: TenantScope, + processor_version: str, + created_at: str, + sheet_ids: Mapping[str, str], + finding_ids: Mapping[tuple[str, str], str], +) -> SpreadsheetAuditManifest: + """Attach server-issued identities without ever copying workbook values.""" + if set(sheet_ids) != {sheet.name for sheet in result.sheets}: + raise ValueError("SHEET_ID_MAPPING_INCOMPLETE") + if len(set(sheet_ids.values())) != len(sheet_ids): + raise ValueError("SHEET_ID_MAPPING_DUPLICATE") + sheets = tuple( + SpreadsheetAuditManifestSheet( + sheetId=sheet_ids[sheet.name], + name=sheet.name, + maxRow=sheet.maxRow, + maxColumn=sheet.maxColumn, + formulaCount=sheet.formulaCount, + ) + for sheet in result.sheets + ) + findings = tuple( + SpreadsheetAuditManifestFinding( + findingId=finding_ids[(finding.sheet, finding.address)], + sheetId=sheet_ids[finding.sheet], + address=finding.address.upper(), + kind=finding.kind, + severity="WARNING", + formulaFingerprint=finding.formulaFingerprint, + ) + for finding in result.findings + ) + return SpreadsheetAuditManifest( + schemaVersion=1, + auditId=audit_id, + artifactVersionId=artifact_version_id, + tenantScope=tenant_scope, + workbookSha256=result.workbookSha256, + sheets=sheets, + findings=findings, + blockedReasons=result.blockedReasons, + processorVersion=processor_version, + createdAt=created_at, + ) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index 01112531..409bff91 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -5,7 +5,11 @@ import pytest -from databreeze_engine.processors.spreadsheet_auditor import SpreadsheetAuditError, audit_workbook +from databreeze_engine.processors import ( + SpreadsheetAuditError, + audit_workbook, + build_spreadsheet_audit_manifest, +) def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: @@ -51,3 +55,43 @@ def test_audit_rejects_archive_traversal_and_cell_resource_exhaustion() -> None: audit_workbook(output.getvalue()) with pytest.raises(SpreadsheetAuditError, match="RESOURCE_LIMIT"): audit_workbook(_workbook(), max_cells=1) + + +def test_manifest_adds_opaque_identities_without_source_values() -> None: + result = audit_workbook(_workbook()) + manifest = build_spreadsheet_audit_manifest( + result, + audit_id="55555555-5555-4555-8555-555555555555", + artifact_version_id="66666666-6666-4666-8666-666666666666", + tenant_scope={ + "scopeType": "workspace", + "organizationId": "22222222-2222-4222-8222-222222222222", + "workspaceId": "33333333-3333-4333-8333-333333333333", + }, + processor_version="spreadsheet-auditor-0.1.0", + created_at="2026-08-04T00:00:00.000Z", + sheet_ids={"Inventory": "77777777-7777-4777-8777-777777777777"}, + finding_ids={("Inventory", "C1"): "88888888-8888-4888-8888-888888888888"}, + ) + encoded = manifest.model_dump_json() + assert "SUM(B1:D1)" not in encoded + assert "A1" not in encoded + assert manifest.findings[0].sheetId == "77777777-7777-4777-8777-777777777777" + + +def test_manifest_requires_complete_server_identity_mappings() -> None: + with pytest.raises(ValueError, match="SHEET_ID_MAPPING_INCOMPLETE"): + build_spreadsheet_audit_manifest( + audit_workbook(_workbook()), + audit_id="55555555-5555-4555-8555-555555555555", + artifact_version_id="66666666-6666-4666-8666-666666666666", + tenant_scope={ + "scopeType": "workspace", + "organizationId": "22222222-2222-4222-8222-222222222222", + "workspaceId": "33333333-3333-4333-8333-333333333333", + }, + processor_version="spreadsheet-auditor-0.1.0", + created_at="2026-08-04T00:00:00.000Z", + sheet_ids={}, + finding_ids={}, + ) From 45c4ed0c4fb70549d21c13e60468578fc7cd769d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:18:22 +0700 Subject: [PATCH 70/74] docs(sa): record vertical slice evidence and limits --- .../sa-spreadsheet-auditor-slice.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/release-evidence/sa-spreadsheet-auditor-slice.md diff --git a/docs/release-evidence/sa-spreadsheet-auditor-slice.md b/docs/release-evidence/sa-spreadsheet-auditor-slice.md new file mode 100644 index 00000000..bfa61d45 --- /dev/null +++ b/docs/release-evidence/sa-spreadsheet-auditor-slice.md @@ -0,0 +1,41 @@ +# Spreadsheet Auditor vertical-slice evidence + +This record describes the implemented checkpoint on the Spreadsheet Auditor plan. It is +deliberately marked **partial**: it does not release SA-001..SA-027 or replace the full +module gate in `docs/plans/110-spreadsheet-auditor.md`. + +## Included in this checkpoint + +- Safe deterministic XLSX inventory and formula-family anomaly detection in + `services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py`. +- A Python manifest bridge that adds server-issued opaque identities and tenant scope without + copying workbook values in + `services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py`. +- The canonical value-free TypeScript result contract in + `packages/domain/src/spreadsheet-audit/v1.ts`. +- Tenant-scoped API registration, lookup, and artifact-version listing under + `/v1/spreadsheet-audits`. +- Immutable in-memory and Prisma adapters with the `sa` PostgreSQL schema and migration. +- Unknown-field rejection tests proving formulas, source values, and raw rows cannot enter the + HTTP result boundary. + +## Evidence collected + +- Domain build, public API smoke test, and domain test suite pass. +- API typecheck, API test compilation, targeted API controller/adapter tests, OpenAPI generation, + Prisma validation/generation, and migration inventory checks pass. +- Python sources and tests pass `python -m py_compile`. + +The Python `uv` test command remains blocked by the existing Windows engine environment: the +checked-in `.venv\Scripts\python.exe` exits with `0xc0e90002` before pytest starts. Recreate or +repair that environment in a dedicated follow-up task; do not mark the engine requirement +verified from the compile-only result. + +## Safety and rollback + +- The parser rejects archive traversal, duplicate members, XML entity/DTD payloads, and resource + exhaustion; macros and external links are disclosed as blocked reasons and never executed. +- Result persistence is immutable and tenant scoped. Replaying the same audit ID is idempotent; + conflicting content fails closed. +- The slice is independently reversible through the commits on + `feat/artifacts-datasets-completion`; the next integration step is a reviewed PR to `dev`. From 203cc7a13972a60901df43768c4d9d1dc4c67c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:23:24 +0700 Subject: [PATCH 71/74] fix(foundation): keep application layers adapter-independent --- packages/domain/src/protected-document/v1.ts | 3 ++- packages/domain/src/spreadsheet-audit/v1.ts | 21 ++++++++++--------- .../application/artifact-upload.service.ts | 3 +-- .../protected-document-unlock.service.ts | 3 +-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/domain/src/protected-document/v1.ts b/packages/domain/src/protected-document/v1.ts index cfec850b..31481aaa 100644 --- a/packages/domain/src/protected-document/v1.ts +++ b/packages/domain/src/protected-document/v1.ts @@ -158,7 +158,8 @@ export function recordProtectedDocumentUnlockResultV1( }, ): ProtectedDocumentUnlockResultV1 { if (request.state !== 'REQUESTED') return rejected('INVALID_STATE'); - if (input.expectedRevision !== request.revision) return rejected('REVISION_CONFLICT'); + const expectedRevision = revision(input.expectedRevision); + if (expectedRevision !== request.revision) return rejected('REVISION_CONFLICT'); const occurredAt = timestamp(input.occurredAt); if (!occurredAt) return rejected('INVALID_TIMESTAMP'); if (Date.parse(occurredAt) >= Date.parse(request.expiresAt)) return rejected('EXPIRED'); diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts index 651a14c5..8e3ca9a2 100644 --- a/packages/domain/src/spreadsheet-audit/v1.ts +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -89,6 +89,10 @@ function hash(input: unknown): string | undefined { : undefined; } +function isBlockedReason(input: unknown): input is SpreadsheetAuditBlockedReasonV1 { + return input === 'MACRO' || input === 'EXTERNAL_LINK' || input === 'UNSUPPORTED_XML'; +} + function count(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; } @@ -182,15 +186,12 @@ export function createSpreadsheetAuditResultV1(input: { return rejected('INVALID_IDENTIFIER'); if (!Array.isArray(input.blockedReasons) || input.blockedReasons.length > 3) return rejected('INVALID_BLOCKED_REASON'); - const blockedReasons = input.blockedReasons; - if ( - blockedReasons.some( - (candidate) => - candidate !== 'MACRO' && candidate !== 'EXTERNAL_LINK' && candidate !== 'UNSUPPORTED_XML', - ) - ) - return rejected('INVALID_BLOCKED_REASON'); - if (new Set(blockedReasons).size !== blockedReasons.length) + const validBlockedReasons: SpreadsheetAuditBlockedReasonV1[] = []; + for (const candidate of input.blockedReasons) { + if (!isBlockedReason(candidate)) return rejected('INVALID_BLOCKED_REASON'); + validBlockedReasons.push(candidate); + } + if (new Set(validBlockedReasons).size !== validBlockedReasons.length) return rejected('INVALID_BLOCKED_REASON'); return Object.freeze({ accepted: true, @@ -202,7 +203,7 @@ export function createSpreadsheetAuditResultV1(input: { workbookSha256, sheets: Object.freeze(validSheets), findings: Object.freeze(validFindings), - blockedReasons: Object.freeze([...blockedReasons] as SpreadsheetAuditBlockedReasonV1[]), + blockedReasons: Object.freeze(validBlockedReasons), processorVersion, createdAt, }), diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts index 5c69f0ad..211e2b96 100644 --- a/services/api/src/features/iae/application/artifact-upload.service.ts +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -11,7 +11,6 @@ import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { ArtifactUploadRepositoryPortV1 } from './artifact-upload-repository.port.js'; -import { InMemoryArtifactUploadStorageAdapter } from '../adapter/in-memory-artifact-upload-storage.adapter.js'; import type { ArtifactUploadPartTransferV1, ArtifactUploadStoragePortV1, @@ -28,7 +27,7 @@ export type ArtifactUploadServiceResultV1 = export class ArtifactUploadService { public constructor( private readonly repository: ArtifactUploadRepositoryPortV1, - private readonly storage: ArtifactUploadStoragePortV1 = new InMemoryArtifactUploadStorageAdapter(), + private readonly storage: ArtifactUploadStoragePortV1, ) {} public async create( diff --git a/services/api/src/features/iae/application/protected-document-unlock.service.ts b/services/api/src/features/iae/application/protected-document-unlock.service.ts index 6c0c9454..cc654785 100644 --- a/services/api/src/features/iae/application/protected-document-unlock.service.ts +++ b/services/api/src/features/iae/application/protected-document-unlock.service.ts @@ -13,7 +13,6 @@ import type { ProtectedDocumentSecretInputResultV1, ProtectedDocumentUnlockHandleV1, } from './protected-document-secret-input.port.js'; -import { InMemoryProtectedDocumentSecretInputAdapter } from '../adapter/in-memory-protected-document-secret-input.adapter.js'; import type { ProtectedDocumentUnlockRepositoryPortV1 } from './protected-document-unlock-repository.port.js'; export type ProtectedDocumentUnlockServiceErrorV1 = @@ -28,7 +27,7 @@ export type ProtectedDocumentUnlockServiceResultV1 = export class ProtectedDocumentUnlockService { public constructor( private readonly requests: ProtectedDocumentUnlockRepositoryPortV1, - private readonly secretInput: ProtectedDocumentSecretInputPortV1 = new InMemoryProtectedDocumentSecretInputAdapter(), + private readonly secretInput: ProtectedDocumentSecretInputPortV1, ) {} public async create( From a110846094ce9837a87b2df6bbebd7bf17e09053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:27:35 +0700 Subject: [PATCH 72/74] fix(sa): preserve formula geometry and formatting gates --- services/api/src/features/iae/iae.module.ts | 4 +++- .../in-memory-spreadsheet-audit-repository.adapter.ts | 9 ++------- .../sa/application/spreadsheet-audit.service.ts | 5 +---- .../processors/spreadsheet_auditor.py | 11 ++++++++++- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 458fc870..300ca017 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -204,7 +204,9 @@ export class IaeModule { options.protectedDocumentUnlockRepository ?? (options.protectedDocumentUnlockDatabase === undefined ? new InMemoryProtectedDocumentUnlockRepositoryAdapter() - : new PrismaProtectedDocumentUnlockRepositoryAdapter(options.protectedDocumentUnlockDatabase)), + : new PrismaProtectedDocumentUnlockRepositoryAdapter( + options.protectedDocumentUnlockDatabase, + )), }, { provide: PROTECTED_DOCUMENT_SECRET_INPUT_PORT, diff --git a/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts index 9fb2bb66..43ff005f 100644 --- a/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts +++ b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts @@ -1,7 +1,4 @@ -import { - tenantScopeContainsV1, - type TenantScopeV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { SpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -24,9 +21,7 @@ function clone(result: SpreadsheetAuditResultV1): SpreadsheetAuditResultV1 { }); } -export class InMemorySpreadsheetAuditRepositoryAdapter - implements SpreadsheetAuditRepositoryPortV1 -{ +export class InMemorySpreadsheetAuditRepositoryAdapter implements SpreadsheetAuditRepositoryPortV1 { private results = new Map(); private transactionTail: Promise = Promise.resolve(); diff --git a/services/api/src/features/sa/application/spreadsheet-audit.service.ts b/services/api/src/features/sa/application/spreadsheet-audit.service.ts index 8d4f7e7c..36c5377f 100644 --- a/services/api/src/features/sa/application/spreadsheet-audit.service.ts +++ b/services/api/src/features/sa/application/spreadsheet-audit.service.ts @@ -3,10 +3,7 @@ import { type SpreadsheetAuditResultValidationV1, type SpreadsheetAuditResultV1, } from '@databreeze/domain/spreadsheet-audit/v1'; -import { - parseStableIdentifierV1, - tenantScopeContainsV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { parseStableIdentifierV1, tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { SpreadsheetAuditRepositoryPortV1 } from './spreadsheet-audit-repository.port.js'; diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py index 9c1add53..fb864479 100644 --- a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -94,8 +94,17 @@ def _cell_address(reference: str) -> tuple[int, int] | None: def _normalized_formula(value: str) -> str: + """Normalize row movement while retaining formula range geometry.""" normalized = _FORMULA_SPACE.sub(" ", value.strip().upper()) - return _FORMULA_REFERENCE.sub("#CELL", normalized) + + def reference(match: re.Match[str]) -> str: + token = match.group(0).replace("$", "") + column = re.match(r"[A-Z]{1,3}", token) + if column is None: + return "#CELL" + return f"{column.group(0)}#ROW" + + return _FORMULA_REFERENCE.sub(reference, normalized) def _fingerprint(value: str) -> str: From b85d8433dc529b5537992dfdef5df26d6a48a5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:32:22 +0700 Subject: [PATCH 73/74] fix(engine): satisfy deterministic processor lint gates --- .../processors/dataset_quality.py | 4 +++- .../engine/tests/test_spreadsheet_auditor.py | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/dataset_quality.py b/services/engine/src/databreeze_engine/processors/dataset_quality.py index 42708e21..264fa8f1 100644 --- a/services/engine/src/databreeze_engine/processors/dataset_quality.py +++ b/services/engine/src/databreeze_engine/processors/dataset_quality.py @@ -18,7 +18,9 @@ class QualityFinding(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - ruleId: StrictStr = Field(pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + ruleId: StrictStr = Field( + pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + ) severity: QualitySeverity messageCode: StrictStr = Field(pattern=r"^[A-Z][A-Z0-9_.-]{0,95}$") occurrenceCount: StrictInt = Field(ge=0) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index 409bff91..7e1c175f 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -13,9 +13,21 @@ def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: - workbook = b'''''' - relationships = b'''''' - sheet = b'''SUM(B1:C1)3SUM(B1:C1)3SUM(B1:D1)4''' + workbook = ( + b'' + b'' + ) + relationships = ( + b'' + b'' + ) + sheet = ( + b'' + b'SUM(B1:C1)3' + b'SUM(B1:C1)3' + b'SUM(B1:D1)4' + ) output = io.BytesIO() with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr("xl/workbook.xml", workbook) From 545977873b05f343e39b2b35121af6bedbdfb1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:33:19 +0700 Subject: [PATCH 74/74] fix(engine): match Ruff processor formatting --- .../engine/src/databreeze_engine/processors/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index 4c676897..4e26bbac 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1,5 +1,10 @@ """Reviewed built-in processors composed into the closed registry.""" -from .spreadsheet_auditor import SpreadsheetAuditError, SpreadsheetAuditResult, audit_workbook + +from .spreadsheet_auditor import ( + SpreadsheetAuditError, + SpreadsheetAuditResult, + audit_workbook, +) from .spreadsheet_auditor_manifest import ( SpreadsheetAuditManifest, SpreadsheetAuditManifestFinding,