From 68e0e4aef2ea0aad672d2abbed1776694488bcd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:28:58 +0700 Subject: [PATCH 01/30] fix(iae): bind admission to repository artifacts --- .../application/artifact-admission.service.ts | 2 +- .../iae/artifact-admission.service.test.ts | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iae/application/artifact-admission.service.ts b/services/api/src/features/iae/application/artifact-admission.service.ts index b36451d8..8f9bd581 100644 --- a/services/api/src/features/iae/application/artifact-admission.service.ts +++ b/services/api/src/features/iae/application/artifact-admission.service.ts @@ -31,7 +31,7 @@ export class ArtifactAdmissionService { 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 }); + const admission = finalizeArtifactAdmissionV1({ ...input, artifact }); if (!admission.accepted) return admission; const updated = await transaction.updateVersionStatus( context, diff --git a/services/api/test/features/iae/artifact-admission.service.test.ts b/services/api/test/features/iae/artifact-admission.service.test.ts index 5c494f34..9c62c684 100644 --- a/services/api/test/features/iae/artifact-admission.service.test.ts +++ b/services/api/test/features/iae/artifact-admission.service.test.ts @@ -59,3 +59,51 @@ void test('IAE-009/010 admission updates only the status projection after scanne }); assert.deepEqual(rejected, { accepted: false, code: 'DIGEST_MISMATCH' }); }); + +void test('IAE-009 admission never lets request input replace the repository artifact', 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', + }); + const attackerArtifact = createArtifactVersionV1({ + artifactId: '77777777-7777-4777-8777-777777777777', + versionId: '88888888-8888-4888-8888-888888888888', + tenantScope: context.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'b'.repeat(64), + byteSize: 4, + mediaType: 'text/csv', + displayName: 'attacker.csv', + createdAt: '2026-08-02T00:00:00.000Z', + status: 'QUARANTINED', + }); + assert.equal(artifact.accepted, true); + assert.equal(attackerArtifact.accepted, true); + if (!artifact.accepted || !attackerArtifact.accepted) return; + await repository.saveVersion(context, artifact.value); + + const untrustedInput = { + actualSha256: 'b'.repeat(64), + actualByteSize: 4, + detectedMediaType: 'text/csv', + scanState: 'CLEAN' as const, + maxByteSize: 100, + artifact: attackerArtifact.value, + }; + const result = await service.admit(context, artifact.value.versionId, untrustedInput); + + assert.deepEqual(result, { accepted: false, code: 'DIGEST_MISMATCH' }); + assert.equal((await repository.findVersion(context, artifact.value.versionId))?.status, 'QUARANTINED'); +}); From 069c0cdb8600d4a1ec7d131ae99c592845da26ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:29:42 +0700 Subject: [PATCH 02/30] fix(engine): bound spreadsheet XML reads --- .../processors/spreadsheet_auditor.py | 13 ++++++++++--- services/engine/tests/test_spreadsheet_auditor.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py index afaa4270..97829224 100644 --- a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -80,6 +80,13 @@ def _xml(data: bytes) -> Xml.Element: raise SpreadsheetAuditError("MALFORMED_XML") from None +def _xml_member(archive: zipfile.ZipFile, name: str) -> Xml.Element: + """Read at most one byte beyond the XML budget before rejecting a member.""" + with archive.open(name, "r") as member: + data = member.read(_MAX_XML_BYTES + 1) + return _xml(data) + + def _column_number(column: str) -> int: value = 0 for character in column.upper(): @@ -140,8 +147,8 @@ def _relationships(root: Xml.Element) -> dict[str, str]: 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"))) + workbook = _xml_member(archive, "xl/workbook.xml") + relationships = _relationships(_xml_member(archive, "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") @@ -213,7 +220,7 @@ def audit_workbook( for sheet_name, target in targets: if target not in names: raise SpreadsheetAuditError("INVALID_ARCHIVE") - root = _xml(archive.read(target)) + root = _xml_member(archive, target) max_row = 0 max_column = 0 cells: list[tuple[str, str | None]] = [] diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index ffc8e7c9..759a05d8 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -106,6 +106,19 @@ def test_audit_rejects_archive_traversal_and_cell_resource_exhaustion() -> None: audit_workbook(_workbook(), max_cells=1) +def test_audit_streams_xml_members_through_a_bounded_reader( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def reject_unbounded_read(*_args: object, **_kwargs: object) -> bytes: + raise AssertionError("ZipFile.read must not decompress untrusted XML without a bound") + + monkeypatch.setattr(zipfile.ZipFile, "read", reject_unbounded_read) + + result = audit_workbook(_workbook()) + + assert result.sheets[0].name == "Inventory" + + def test_manifest_adds_opaque_identities_without_source_values() -> None: result = audit_workbook(_workbook()) manifest = build_spreadsheet_audit_manifest( From 718b40664630eb3de7e29de4cc0ed7c084d764a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:30:16 +0700 Subject: [PATCH 03/30] test(iae): emulate Prisma uniqueness in fixtures --- .../features/iae/prisma-artifact-export-repository.test.ts | 3 +++ .../features/iae/prisma-artifact-intake-repository.test.ts | 3 +++ 2 files changed, 6 insertions(+) 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 10482b69..3e03102f 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 @@ -53,6 +53,9 @@ void test('IAE-018 Prisma export adapter preserves immutable manifests and scope artifactExportManifestRecord: { create({ data }) { const row = { ...data }; + if (rows.has(row.id)) { + throw Object.assign(new Error('fixture unique constraint violation'), { code: 'P2002' }); + } rows.set(row.id, row); return Promise.resolve(row); }, 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 d16113f5..8bc8ff36 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 @@ -56,6 +56,9 @@ function client(rows: ArtifactIntakeDatabaseRowV1[]): ArtifactIntakeDatabaseClie create(input) { const created = { ...input.data }; const persisted = { ...created } as ArtifactIntakeDatabaseRowV1; + if (rows.some((candidate) => candidate.id === persisted.id)) { + throw Object.assign(new Error('fixture unique constraint violation'), { code: 'P2002' }); + } rows.push(persisted); return Promise.resolve(persisted); }, From 5ed2cb470d39a6f395195b07dc6aab2f348c9012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:30:46 +0700 Subject: [PATCH 04/30] fix(engine): tolerate sparse quality state counts --- .../processors/dataset_quality.py | 6 +++--- services/engine/tests/test_dataset_quality.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/dataset_quality.py b/services/engine/src/databreeze_engine/processors/dataset_quality.py index 264fa8f1..dbab8c50 100644 --- a/services/engine/src/databreeze_engine/processors/dataset_quality.py +++ b/services/engine/src/databreeze_engine/processors/dataset_quality.py @@ -57,9 +57,9 @@ 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"] + summary.stateCounts.get("MISSING", 0) + + summary.stateCounts.get("NULL", 0) + + summary.stateCounts.get("BLANK", 0) ) return None diff --git a/services/engine/tests/test_dataset_quality.py b/services/engine/tests/test_dataset_quality.py index 30b454ec..f02115f9 100644 --- a/services/engine/tests/test_dataset_quality.py +++ b/services/engine/tests/test_dataset_quality.py @@ -45,6 +45,26 @@ def test_missing_profiled_field_is_disclosed_and_error_blocks() -> None: assert result.findings[0].occurrenceCount == 1 +def test_required_quality_treats_omitted_zero_state_counts_as_zero() -> None: + profile = profile_records([{"code": "A"}], ["code"]) + summary = profile.fields[0].model_copy(update={"stateCounts": {"VALUE": 1}}) + sparse_profile = profile.model_copy(update={"fields": (summary,)}) + + result = evaluate_required_fields( + sparse_profile, + [ + { + "ruleId": "00000000-0000-4000-8000-000000000003", + "field": "code", + "severity": "ERROR", + } + ], + ) + + assert result.qualityState == "PASS" + assert result.findings == () + + def test_invalid_rule_shape_fails_closed() -> None: profile = profile_records([{"code": "A"}], ["code"]) try: From 96553d0b242833e139c909c6eb832208e0f7f950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:31:35 +0700 Subject: [PATCH 05/30] fix(sa): reject duplicate blocked reasons --- services/api/src/features/sa/api/spreadsheet-audit.dto.ts | 2 ++ .../test/features/sa/spreadsheet-audit.controller.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts index c562acba..455cf261 100644 --- a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts +++ b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts @@ -2,6 +2,7 @@ import { Type } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, + ArrayUnique, IsArray, IsIn, IsInt, @@ -107,6 +108,7 @@ export class CreateSpreadsheetAuditResultDto { @ApiProperty({ enum: ['MACRO', 'EXTERNAL_LINK', 'UNSUPPORTED_XML'], isArray: true }) @IsArray() @ArrayMaxSize(3) + @ArrayUnique() @IsIn(['MACRO', 'EXTERNAL_LINK', 'UNSUPPORTED_XML'], { each: true }) blockedReasons!: Array<'MACRO' | 'EXTERNAL_LINK' | 'UNSUPPORTED_XML'>; diff --git a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts index 5f0dca75..77b5681b 100644 --- a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -68,6 +68,13 @@ void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source delete rejectedWithoutCorrelation['correlationId']; assert.doesNotMatch(JSON.stringify(rejectedWithoutCorrelation), /SUM|42|sourceValue/iu); + const duplicateReason = await app.inject({ + method: 'POST', + url: '/v1/spreadsheet-audits', + payload: { ...payload, blockedReasons: ['MACRO', 'MACRO'] }, + }); + assert.equal(duplicateReason.statusCode, 400); + const created = await app.inject({ method: 'POST', url: '/v1/spreadsheet-audits', From 6d67783a97c004b78fbafe09cd25279520715625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:32:44 +0700 Subject: [PATCH 06/30] fix(sa): serialize in-memory audit writes --- ...ry-spreadsheet-audit-repository.adapter.ts | 27 +++++++++++++++-- .../sa/spreadsheet-audit.service.test.ts | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) 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 43ff005f..c3634fd6 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 @@ -26,6 +26,13 @@ export class InMemorySpreadsheetAuditRepositoryAdapter implements SpreadsheetAud private transactionTail: Promise = Promise.resolve(); public async save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise { + await this.withTransaction(context, (transaction) => transaction.save(context, result)); + } + + private async saveUnlocked( + context: IamTenantContextV1, + result: SpreadsheetAuditResultV1, + ): Promise { await Promise.resolve(); if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) throw new Error('SA_SCOPE_NARROWING_REQUIRED'); @@ -38,6 +45,13 @@ export class InMemorySpreadsheetAuditRepositoryAdapter implements SpreadsheetAud public async find( context: IamTenantContextV1, auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise { + return this.findUnlocked(context, auditId); + } + + private async findUnlocked( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], ): Promise { await Promise.resolve(); const result = this.results.get(auditId); @@ -47,6 +61,13 @@ export class InMemorySpreadsheetAuditRepositoryAdapter implements SpreadsheetAud public async list( context: IamTenantContextV1, artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise { + return this.listUnlocked(context, artifactVersionId); + } + + private async listUnlocked( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], ): Promise { await Promise.resolve(); return [...this.results.values()] @@ -72,9 +93,9 @@ export class InMemorySpreadsheetAuditRepositoryAdapter implements SpreadsheetAud 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), + save: this.saveUnlocked.bind(this), + find: this.findUnlocked.bind(this), + list: this.listUnlocked.bind(this), }); } catch (error) { this.results = before; diff --git a/services/api/test/features/sa/spreadsheet-audit.service.test.ts b/services/api/test/features/sa/spreadsheet-audit.service.test.ts index 1b9aa151..875c9f4a 100644 --- a/services/api/test/features/sa/spreadsheet-audit.service.test.ts +++ b/services/api/test/features/sa/spreadsheet-audit.service.test.ts @@ -1,6 +1,8 @@ import { strict as assert } from 'node:assert'; import test from 'node:test'; +import { createSpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; + 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'; @@ -88,3 +90,31 @@ void test('[SA-005] service hides results from a different organization', async code: 'AUDIT_NOT_FOUND', }); }); + +void test('SA repository serializes public saves after a rolling-back transaction', async () => { + const repository = new InMemorySpreadsheetAuditRepositoryAdapter(); + const created = createSpreadsheetAuditResultV1(input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + let enterTransaction!: () => void; + const transactionEntered = new Promise((resolve) => { + enterTransaction = resolve; + }); + let releaseTransaction!: () => void; + const transactionRelease = new Promise((resolve) => { + releaseTransaction = resolve; + }); + const rollingBack = repository.withTransaction(context, async () => { + enterTransaction(); + await transactionRelease; + throw new Error('ROLLBACK'); + }); + await transactionEntered; + + const saving = repository.save(context, created.value); + releaseTransaction(); + await assert.rejects(rollingBack, /ROLLBACK/u); + await saving; + + assert.deepEqual(await repository.find(context, created.value.auditId), created.value); +}); From 8b31681127c332a02e8f2ba6f30448c96dfdcb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:33:41 +0700 Subject: [PATCH 07/30] fix(sa): require strict UTC audit timestamps --- services/api/src/features/sa/api/spreadsheet-audit.dto.ts | 3 ++- .../test/features/sa/spreadsheet-audit.controller.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts index 455cf261..5d8bfd12 100644 --- a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts +++ b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts @@ -118,6 +118,7 @@ export class CreateSpreadsheetAuditResultDto { processorVersion!: string; @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @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/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts index 77b5681b..17a4e22c 100644 --- a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -75,6 +75,13 @@ void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source }); assert.equal(duplicateReason.statusCode, 400); + const nonUtcTimestamp = await app.inject({ + method: 'POST', + url: '/v1/spreadsheet-audits', + payload: { ...payload, createdAt: '2026-08-04T07:00:00.000+07:00' }, + }); + assert.equal(nonUtcTimestamp.statusCode, 400); + const created = await app.inject({ method: 'POST', url: '/v1/spreadsheet-audits', From 3bfe6006965194d071597cd755b473bba23c41fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:35:05 +0700 Subject: [PATCH 08/30] fix(api): publish bounded collection contracts --- services/api/openapi/v1.json | 24 +++++++++++++++---- .../features/dsm/api/dataset-quality.dto.ts | 4 ++-- .../features/dsm/api/dataset-version.dto.ts | 2 +- .../features/dsm/api/governed-dataset.dto.ts | 4 +++- .../api/src/features/dsm/api/mapping.dto.ts | 7 ++++-- .../features/iae/api/artifact-export.dto.ts | 2 +- services/api/test/openapi.test.ts | 13 ++++++++++ 7 files changed, 45 insertions(+), 11 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index a5706833..8df95684 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -7566,7 +7566,12 @@ "type": "object", "properties": { "manifestId": { "type": "string", "format": "uuid" }, - "versionIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "versionIds": { + "minItems": 1, + "maxItems": 1024, + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, "approvalState": { "type": "string", "enum": ["NOT_REQUIRED", "PENDING", "APPROVED", "REJECTED"] @@ -7716,6 +7721,7 @@ "versionId": { "type": "string", "format": "uuid" }, "name": { "type": "string", "maxLength": 200 }, "fields": { + "maxItems": 256, "type": "array", "items": { "$ref": "#/components/schemas/GovernedDatasetFieldDto" } }, @@ -7759,7 +7765,11 @@ "versionId": { "type": "string", "format": "uuid" }, "sourceSchemaVersionId": { "type": "string", "format": "uuid" }, "targetSchemaVersionId": { "type": "string", "format": "uuid" }, - "steps": { "type": "array", "items": { "$ref": "#/components/schemas/MappingStepDto" } }, + "steps": { + "maxItems": 512, + "type": "array", + "items": { "$ref": "#/components/schemas/MappingStepDto" } + }, "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, @@ -7785,7 +7795,7 @@ "properties": { "versionId": { "type": "string", "format": "uuid" }, "schemaVersionId": { "type": "string", "format": "uuid" }, - "rules": { "type": "array", "items": { "type": "object" } }, + "rules": { "maxItems": 512, "type": "array", "items": { "type": "object" } }, "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, @@ -7832,6 +7842,7 @@ "properties": { "datasetId": { "type": "string", "format": "uuid" }, "inputArtifactVersionIds": { + "maxItems": 1024, "type": "array", "items": { "type": "string", "format": "uuid" } }, @@ -7903,7 +7914,11 @@ "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" } }, + "evidenceIds": { + "maxItems": 128, + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, "detailHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "subject": { "$ref": "#/components/schemas/DatasetQualityFindingSubjectDto" }, "actual": { "$ref": "#/components/schemas/DatasetQualitySafeValueDto" }, @@ -7933,6 +7948,7 @@ "enum": ["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] }, "findings": { + "maxItems": 512, "type": "array", "items": { "$ref": "#/components/schemas/DatasetQualityFindingDto" } }, 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 d9293b70..91cc51d1 100644 --- a/services/api/src/features/dsm/api/dataset-quality.dto.ts +++ b/services/api/src/features/dsm/api/dataset-quality.dto.ts @@ -100,7 +100,7 @@ export class DatasetQualityFindingDto { @Max(Number.MAX_SAFE_INTEGER) occurrenceCount!: number; - @ApiProperty({ type: [String], format: 'uuid' }) + @ApiProperty({ type: [String], format: 'uuid', maxItems: 128 }) @IsArray() @ArrayMaxSize(128) @IsUUID('4', { each: true }) @@ -162,7 +162,7 @@ export class RegisterDatasetQualityResultDto { @IsIn(['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE']) qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; - @ApiProperty({ type: [DatasetQualityFindingDto] }) + @ApiProperty({ type: [DatasetQualityFindingDto], maxItems: 512 }) @IsArray() @ArrayMaxSize(512) @ValidateNested({ each: true }) 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 ba6b0cc5..aa555ec4 100644 --- a/services/api/src/features/dsm/api/dataset-version.dto.ts +++ b/services/api/src/features/dsm/api/dataset-version.dto.ts @@ -18,7 +18,7 @@ export class RegisterDatasetVersionDto { @IsUUID() datasetId!: string; - @ApiProperty({ format: 'uuid', type: [String] }) + @ApiProperty({ format: 'uuid', type: [String], maxItems: 1024 }) @IsArray() @ArrayMaxSize(1024) @IsUUID('4', { each: true }) 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 44093518..d1273a26 100644 --- a/services/api/src/features/dsm/api/governed-dataset.dto.ts +++ b/services/api/src/features/dsm/api/governed-dataset.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + ArrayMaxSize, IsArray, IsBoolean, IsIn, @@ -80,8 +81,9 @@ export class CreateGovernedDatasetDto { @MaxLength(200) name!: string; - @ApiProperty({ type: [GovernedDatasetFieldDto] }) + @ApiProperty({ type: [GovernedDatasetFieldDto], maxItems: 256 }) @IsArray() + @ArrayMaxSize(256) @ValidateNested({ each: true }) @Type(() => GovernedDatasetFieldDto) fields!: GovernedDatasetFieldDto[]; diff --git a/services/api/src/features/dsm/api/mapping.dto.ts b/services/api/src/features/dsm/api/mapping.dto.ts index 3b613e6a..3c8e1ed0 100644 --- a/services/api/src/features/dsm/api/mapping.dto.ts +++ b/services/api/src/features/dsm/api/mapping.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + ArrayMaxSize, IsArray, IsIn, IsISO8601, @@ -54,8 +55,9 @@ export class CreateMappingDto { @IsUUID() targetSchemaVersionId!: string; - @ApiProperty({ type: [MappingStepDto] }) + @ApiProperty({ type: [MappingStepDto], maxItems: 512 }) @IsArray() + @ArrayMaxSize(512) @ValidateNested({ each: true }) @Type(() => MappingStepDto) steps!: MappingStepDto[]; @@ -80,8 +82,9 @@ export class CreateRuleSetDto { @IsUUID() schemaVersionId!: string; - @ApiProperty({ type: [Object] }) + @ApiProperty({ type: [Object], maxItems: 512 }) @IsArray() + @ArrayMaxSize(512) @IsObject({ each: true }) rules!: Record[]; diff --git a/services/api/src/features/iae/api/artifact-export.dto.ts b/services/api/src/features/iae/api/artifact-export.dto.ts index 4c0fa9a9..14584399 100644 --- a/services/api/src/features/iae/api/artifact-export.dto.ts +++ b/services/api/src/features/iae/api/artifact-export.dto.ts @@ -6,7 +6,7 @@ export class CreateArtifactExportDto { @IsUUID() manifestId!: string; - @ApiProperty({ type: [String], format: 'uuid' }) + @ApiProperty({ type: [String], format: 'uuid', minItems: 1, maxItems: 1024 }) @IsArray() @ArrayMinSize(1) @ArrayMaxSize(1024) diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 93ddb713..673e829d 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -186,6 +186,19 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, 'refreshToken' ]; assert.equal(refreshToken?.['writeOnly'], undefined); + for (const [schemaName, propertyName, maxItems] of [ + ['CreateArtifactExportDto', 'versionIds', 1024], + ['CreateGovernedDatasetDto', 'fields', 256], + ['CreateMappingDto', 'steps', 512], + ['CreateRuleSetDto', 'rules', 512], + ['RegisterDatasetVersionDto', 'inputArtifactVersionIds', 1024], + ['DatasetQualityFindingDto', 'evidenceIds', 128], + ['RegisterDatasetQualityResultDto', 'findings', 512], + ] as const) { + const schema = firstDocument.components?.schemas?.[schemaName] as Record; + const property = (schema['properties'] as Record>)[propertyName]; + assert.equal(property?.['maxItems'], maxItems, `${schemaName}.${propertyName} must be bounded`); + } for (const operation of operations(firstDocument)) { const headerNames = (operation.parameters ?? []) From fd508f9e1877232df0bbb893e1c0482ca59cc60e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:35:26 +0700 Subject: [PATCH 09/30] test(iae): verify intake transition revisions --- .../features/iae/prisma-artifact-intake-repository.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 8bc8ff36..9d918797 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 @@ -174,10 +174,9 @@ void test('[IAE-013] Prisma adapter persists only validated state transitions wi { ...context(workspaceId, 'transition-update'), expectedRevision: 1 }, { ...item, state: 'ROUTED', revision: 2 }, ); - assert.equal( - (await repository.find(context(workspaceId, 'transition-read'), itemId))?.state, - 'ROUTED', - ); + const transitioned = await repository.find(context(workspaceId, 'transition-read'), itemId); + assert.equal(transitioned?.state, 'ROUTED'); + assert.equal(transitioned?.revision, 2); await assert.rejects( repository.save( { ...context(workspaceId, 'transition-stale'), expectedRevision: 1 }, From 812e0c5c864d216c60aad4317ea6810264590a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:35:54 +0700 Subject: [PATCH 10/30] test(iae): harden inbox content leak assertions --- services/api/test/features/iae/inbox.controller.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api/test/features/iae/inbox.controller.test.ts b/services/api/test/features/iae/inbox.controller.test.ts index fbc4f7e8..f0768333 100644 --- a/services/api/test/features/iae/inbox.controller.test.ts +++ b/services/api/test/features/iae/inbox.controller.test.ts @@ -49,7 +49,7 @@ void test('[IAE-001, IAM-009] HTTP inbox listing uses the configured tenant cont const response = await app.inject({ method: 'GET', url: '/v1/artifacts/inbox' }); assert.equal(response.statusCode, 200); assert.deepEqual(response.json(), [created.accepted ? created.value : undefined]); - assert.doesNotMatch(response.body, /opaque|path|byte|excerpt/u); + assert.doesNotMatch(response.body, /opaque|path|byte|excerpt/iu); } finally { await app.close(); } @@ -103,7 +103,7 @@ void test('[IAE-013] HTTP inbox metadata patch uses a revision precondition and 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); + assert.doesNotMatch(accepted.body, /path|source|byte|excerpt/iu); } finally { await app.close(); } From 42ff542882e59c36b40f217e0310a4843a804ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:36:41 +0700 Subject: [PATCH 11/30] fix(api): document readiness problems by media type --- services/api/openapi/v1.json | 6 ++++-- services/api/src/features/system/api/health.controller.ts | 6 +++++- services/api/test/openapi.test.ts | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 8df95684..d0abe932 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -147,10 +147,12 @@ } }, "503": { - "description": "", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } }, + "description": "", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", diff --git a/services/api/src/features/system/api/health.controller.ts b/services/api/src/features/system/api/health.controller.ts index 37fe04a3..13800384 100644 --- a/services/api/src/features/system/api/health.controller.ts +++ b/services/api/src/features/system/api/health.controller.ts @@ -27,7 +27,11 @@ export class HealthController { @ApiOkResponse({ schema: { properties: { status: { enum: ['ready'], type: 'string' } }, type: 'object' }, }) - @ApiServiceUnavailableResponse({ schema: { $ref: '#/components/schemas/ProblemDetails' } }) + @ApiServiceUnavailableResponse({ + content: { + 'application/problem+json': { schema: { $ref: '#/components/schemas/ProblemDetails' } }, + }, + }) async readinessStatus(): Promise<{ readonly status: 'ready' }> { try { if (await this.readiness.check()) return { status: 'ready' }; diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 673e829d..4af56f15 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -15,6 +15,7 @@ interface ParameterLike { interface ResponseLike { readonly $ref?: string; + readonly content?: Record; readonly headers?: Record; } @@ -242,6 +243,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.ok(auditRead?.responses['200'], `${path} must document its successful response`); assert.ok(auditRead.responses['503'], `${path} must document audit persistence outages`); } + const readiness = firstDocument.paths['/health/ready']?.get as OperationLike | undefined; + assert.ok(readiness?.responses['503']?.content?.['application/problem+json']); const served = await first.app.inject({ method: 'GET', url: '/v1/openapi.json' }); assert.equal(served.statusCode, 200); From f4af924edff8f6b0be3b4003b4a1e62d785348bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:37:27 +0700 Subject: [PATCH 12/30] fix(iae): disclose expired upload transfers --- .../features/iae/application/artifact-upload.service.ts | 7 ++++++- .../api/test/features/iae/artifact-upload.service.test.ts | 2 +- 2 files changed, 7 insertions(+), 2 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 211e2b96..821c91e9 100644 --- a/services/api/src/features/iae/application/artifact-upload.service.ts +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -17,7 +17,10 @@ import type { ArtifactUploadStorageResultV1, } from './artifact-upload-storage.port.js'; -export type ArtifactUploadServiceErrorV1 = 'UPLOAD_NOT_FOUND' | 'UPLOAD_SCOPE_NARROWING_REQUIRED'; +export type ArtifactUploadServiceErrorV1 = + | 'UPLOAD_NOT_FOUND' + | 'UPLOAD_SCOPE_NARROWING_REQUIRED' + | 'UPLOAD_SESSION_EXPIRED'; export type ArtifactUploadServiceResultV1 = | ArtifactUploadResultV1 | ArtifactUploadStorageResultV1 @@ -129,6 +132,8 @@ export class ArtifactUploadService { ): Promise> { const session = await this.repository.find(context, sessionId); if (!session) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + if (session.state === 'EXPIRED') + return Object.freeze({ accepted: false, code: 'UPLOAD_SESSION_EXPIRED' as const }); return this.storage.issuePartTransfer(context, session, partNumber); } 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 1340927a..8c85ddae 100644 --- a/services/api/test/features/iae/artifact-upload.service.test.ts +++ b/services/api/test/features/iae/artifact-upload.service.test.ts @@ -97,5 +97,5 @@ void test('IAE-014 expiration revokes storage-side partial state before persisti 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' }); + assert.deepEqual(transfer, { accepted: false, code: 'UPLOAD_SESSION_EXPIRED' }); }); From e5c49765485a1d86b9e0e286c270f78ae1991bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:38:16 +0700 Subject: [PATCH 13/30] fix(domain): validate normalized export text --- packages/domain/src/artifact-export/v1.ts | 9 +++--- .../domain/test/artifact-export-v1.test.mjs | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/domain/src/artifact-export/v1.ts b/packages/domain/src/artifact-export/v1.ts index 316e95f6..77f9180a 100644 --- a/packages/domain/src/artifact-export/v1.ts +++ b/packages/domain/src/artifact-export/v1.ts @@ -64,11 +64,10 @@ function timestamp(input: unknown): StrictUtcTimestampV1 | 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() + if (typeof input !== 'string') return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= 128 && !/\p{Cc}/u.test(normalized) + ? normalized : undefined; } diff --git a/packages/domain/test/artifact-export-v1.test.mjs b/packages/domain/test/artifact-export-v1.test.mjs index 8e6cfb3b..e6546f63 100644 --- a/packages/domain/test/artifact-export-v1.test.mjs +++ b/packages/domain/test/artifact-export-v1.test.mjs @@ -56,3 +56,33 @@ void test('[IAE-018] export manifests preserve hashes, evidence references, and { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, ); }); + +void test('[IAE-018] processor versions are validated after normalization and trimming', () => { + const base = { + manifestId: '00000000-0000-4000-8000-000000000726', + tenantScope: scope, + entries: [ + { + versionId: '00000000-0000-4000-8000-000000000727', + contentSha256: 'a'.repeat(64), + byteSize: 10, + evidenceIds: [], + processorVersions: [' '], + }, + ], + approvalState: 'PENDING', + createdAt: '2026-01-03T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), + }; + assert.deepEqual(createArtifactExportManifestV1(base), { + accepted: false, + code: 'INVALID_ENTRY', + }); + + const trimmed = createArtifactExportManifestV1({ + ...base, + entries: [{ ...base.entries[0], processorVersions: [`${' '.repeat(128)}v1`] }], + }); + assert.equal(trimmed.accepted, true); + if (trimmed.accepted) assert.deepEqual(trimmed.value.entries[0].processorVersions, ['v1']); +}); From 533e7b7a68594b0bfd3f8b45718545c0d31c2aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:38:34 +0700 Subject: [PATCH 14/30] test(domain): verify aggregate governance exports --- packages/domain/test/built-public-api-smoke.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 88cd28a4..06821858 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -64,6 +64,8 @@ const [ assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUTHORIZATION_SCHEMA_VERSION_V1, 1); +assert.equal(aggregate.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); +assert.equal(aggregate.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(permissions.PERMISSION_SCHEMA_VERSION_V1, 1); assert.equal(typeof tenantScope.parseTenantScopeV1, 'function'); assert.equal(typeof authorization.createScopedAuthorizationEvaluatorV1, 'function'); From eec8df5b616239ba96f67ab496c737ceeb04437a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:38:50 +0700 Subject: [PATCH 15/30] test(sa): assert value-free finding payloads --- packages/domain/test/spreadsheet-audit-v1.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/domain/test/spreadsheet-audit-v1.test.mjs b/packages/domain/test/spreadsheet-audit-v1.test.mjs index 44ed6045..eb0c0957 100644 --- a/packages/domain/test/spreadsheet-audit-v1.test.mjs +++ b/packages/domain/test/spreadsheet-audit-v1.test.mjs @@ -34,8 +34,8 @@ void test('[SA-001, SA-004] audit results retain exact value-free evidence coord 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); + assert.equal(Object.hasOwn(result.value.findings[0], 'formula'), false); + assert.equal(Object.hasOwn(result.value.findings[0], 'sourceValue'), false); }); void test('[SA-005] findings cannot reference an unknown sheet or duplicate IDs', () => { From 6173abf58fab4712241a3fc82c73fcb622d55000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:39:24 +0700 Subject: [PATCH 16/30] fix(domain): classify premature upload expiry --- packages/domain/src/artifact-upload/v1.ts | 2 +- packages/domain/test/artifact-upload-v1.test.mjs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/artifact-upload/v1.ts b/packages/domain/src/artifact-upload/v1.ts index b1e37cc2..1d72423f 100644 --- a/packages/domain/src/artifact-upload/v1.ts +++ b/packages/domain/src/artifact-upload/v1.ts @@ -246,7 +246,7 @@ export function expireArtifactUploadSessionV1( 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'); + if (Date.parse(timestampValue) < Date.parse(session.expiresAt)) return rejected('INVALID_TIMESTAMP'); return accepted( Object.freeze({ ...session, state: 'EXPIRED' as const, revision: session.revision + 1 }), ); diff --git a/packages/domain/test/artifact-upload-v1.test.mjs b/packages/domain/test/artifact-upload-v1.test.mjs index d40778a0..be6a5c1c 100644 --- a/packages/domain/test/artifact-upload-v1.test.mjs +++ b/packages/domain/test/artifact-upload-v1.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import { completeArtifactUploadSessionV1, createArtifactUploadSessionV1, + expireArtifactUploadSessionV1, recordArtifactUploadPartV1, } from '../dist/artifact-upload/v1.js'; @@ -60,3 +61,14 @@ void test('[IAE-014] upload sessions require every bounded part before completio 'COMPLETED', ); }); + +void test('[IAE-014] upload sessions reject a premature expiration timestamp', () => { + const created = createArtifactUploadSessionV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + + assert.deepEqual(expireArtifactUploadSessionV1(created.value, base.createdAt), { + accepted: false, + code: 'INVALID_TIMESTAMP', + }); +}); From 3f769e2c5e5519a5cd8b41c7187677ffd2426c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:40:05 +0700 Subject: [PATCH 17/30] fix(sa): preserve finding validation errors --- packages/domain/src/spreadsheet-audit/v1.ts | 43 +++++++++++-------- .../domain/test/spreadsheet-audit-v1.test.mjs | 13 ++++++ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts index 7f170e1b..bde1fb31 100644 --- a/packages/domain/src/spreadsheet-audit/v1.ts +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -123,8 +123,9 @@ function sheet(input: unknown): SpreadsheetAuditSheetV1 | 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; +function finding(input: unknown): SpreadsheetAuditResultValidationV1 { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + return rejected('INVALID_IDENTIFIER'); const record = input as Record; const findingId = identifier(record['findingId']); const sheetId = identifier(record['sheetId']); @@ -132,18 +133,24 @@ function finding(input: unknown): SpreadsheetAuditFindingV1 | undefined { 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; + if (!findingId || !sheetId) return rejected('INVALID_IDENTIFIER'); + if (!address || !/^[A-Z]{1,3}[1-9][0-9]*$/u.test(address.toUpperCase())) + return rejected('INVALID_COORDINATE'); + if (kind !== 'FORMULA_FAMILY_OUTLIER' && kind !== 'FORMULA_GAP') + return rejected('INVALID_KIND'); + if (severity !== 'INFO' && severity !== 'WARNING' && severity !== 'ERROR') + return rejected('INVALID_SEVERITY'); + if (!formulaFingerprint) return rejected('INVALID_HASH'); return Object.freeze({ - findingId, - sheetId, - address: address.toUpperCase(), - kind: kind as SpreadsheetAuditFindingKindV1, - severity: severity as SpreadsheetAuditSeverityV1, - formulaFingerprint, + accepted: true, + value: Object.freeze({ + findingId, + sheetId, + address: address.toUpperCase(), + kind: kind as SpreadsheetAuditFindingKindV1, + severity: severity as SpreadsheetAuditSeverityV1, + formulaFingerprint, + }), }); } @@ -181,10 +188,12 @@ export function createSpreadsheetAuditResultV1(input: { 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[]; + const validFindings: SpreadsheetAuditFindingV1[] = []; + for (const candidate of input.findings) { + const parsed = finding(candidate); + if (!parsed.accepted) return parsed; + validFindings.push(parsed.value); + } if (new Set(validFindings.map((candidate) => candidate.findingId)).size !== validFindings.length) return rejected('DUPLICATE_IDENTIFIER'); const sheetsById = new Map(validSheets.map((candidate) => [candidate.sheetId, candidate])); diff --git a/packages/domain/test/spreadsheet-audit-v1.test.mjs b/packages/domain/test/spreadsheet-audit-v1.test.mjs index eb0c0957..4c68ce00 100644 --- a/packages/domain/test/spreadsheet-audit-v1.test.mjs +++ b/packages/domain/test/spreadsheet-audit-v1.test.mjs @@ -71,3 +71,16 @@ void test('[SA-006] findings must stay inside the exact sheet geometry', () => { { accepted: false, code: 'INVALID_COORDINATE' }, ); }); + +void test('[SA-004] finding validation preserves structural error codes', () => { + for (const [finding, code] of [ + [{ ...base.findings[0], address: 'not-a-cell' }, 'INVALID_COORDINATE'], + [{ ...base.findings[0], severity: 'CRITICAL' }, 'INVALID_SEVERITY'], + [{ ...base.findings[0], kind: 'UNKNOWN' }, 'INVALID_KIND'], + ]) { + assert.deepEqual(createSpreadsheetAuditResultV1({ ...base, findings: [finding] }), { + accepted: false, + code, + }); + } +}); From adb45efc01c905e3b1e71eab0ec078e220ae8812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:40:26 +0700 Subject: [PATCH 18/30] test(domain): guard completed upload results --- packages/domain/test/artifact-upload-v1.test.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/domain/test/artifact-upload-v1.test.mjs b/packages/domain/test/artifact-upload-v1.test.mjs index be6a5c1c..5ac5db56 100644 --- a/packages/domain/test/artifact-upload-v1.test.mjs +++ b/packages/domain/test/artifact-upload-v1.test.mjs @@ -53,13 +53,13 @@ void test('[IAE-014] upload sessions require every bounded part before completio }); assert.equal(second.accepted, true); if (!second.accepted) return; - assert.equal( - completeArtifactUploadSessionV1(second.value, { - assembledSha256: base.expectedSha256, - expectedRevision: 3, - }).value.state, - 'COMPLETED', - ); + const completed = completeArtifactUploadSessionV1(second.value, { + assembledSha256: base.expectedSha256, + expectedRevision: 3, + }); + assert.equal(completed.accepted, true); + if (!completed.accepted) return; + assert.equal(completed.value.state, 'COMPLETED'); }); void test('[IAE-014] upload sessions reject a premature expiration timestamp', () => { From d477368dbe2616146dfd7711955965e405fc50d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:40:47 +0700 Subject: [PATCH 19/30] fix(domain): classify premature unlock expiry --- packages/domain/src/protected-document/v1.ts | 2 +- packages/domain/test/protected-document-v1.test.mjs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/protected-document/v1.ts b/packages/domain/src/protected-document/v1.ts index 31481aaa..185ff610 100644 --- a/packages/domain/src/protected-document/v1.ts +++ b/packages/domain/src/protected-document/v1.ts @@ -196,7 +196,7 @@ export function expireProtectedDocumentUnlockRequestV1( 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'); + if (Date.parse(timestampValue) < Date.parse(request.expiresAt)) return rejected('INVALID_STATE'); return accepted( Object.freeze({ ...request, state: 'EXPIRED' as const, revision: request.revision + 1 }), ); diff --git a/packages/domain/test/protected-document-v1.test.mjs b/packages/domain/test/protected-document-v1.test.mjs index 6cde0b01..89797d78 100644 --- a/packages/domain/test/protected-document-v1.test.mjs +++ b/packages/domain/test/protected-document-v1.test.mjs @@ -52,6 +52,10 @@ void test('[IAE-015] device-keychain requests require a device and expire withou const created = createProtectedDocumentUnlockRequestV1(base); assert.equal(created.accepted, true); if (!created.accepted) return; + assert.deepEqual( + expireProtectedDocumentUnlockRequestV1(created.value, '2026-08-02T00:29:59.999Z'), + { accepted: false, code: 'INVALID_STATE' }, + ); 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'); From afb3fdc9ca1730d6963f2c820b0aaa243adcc290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:41:18 +0700 Subject: [PATCH 20/30] fix(dsm): enforce dataset profile row budgets --- packages/domain/src/dataset-profile/v1.ts | 1 + packages/domain/test/dataset-profile-v1.test.mjs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/domain/src/dataset-profile/v1.ts b/packages/domain/src/dataset-profile/v1.ts index 2bf20713..7a78f032 100644 --- a/packages/domain/src/dataset-profile/v1.ts +++ b/packages/domain/src/dataset-profile/v1.ts @@ -151,6 +151,7 @@ export function createDatasetProfileV1(input: { 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 (rowCountScanned > maxRows) return rejected('INVALID_COUNT'); if (!profileFingerprint) return rejected('INVALID_HASH'); if (!createdAt) return rejected('INVALID_TIMESTAMP'); return accepted( diff --git a/packages/domain/test/dataset-profile-v1.test.mjs b/packages/domain/test/dataset-profile-v1.test.mjs index c5729e61..9854de9f 100644 --- a/packages/domain/test/dataset-profile-v1.test.mjs +++ b/packages/domain/test/dataset-profile-v1.test.mjs @@ -56,4 +56,11 @@ void test('[DSM-011] complete profiles reject sample-only fields and impossible createDatasetProfileV1({ ...base, completeness: 'COMPLETE', samplingSeed: 'a'.repeat(64) }), { accepted: false, code: 'INVALID_SAMPLING' }, ); + assert.deepEqual( + createDatasetProfileV1({ + ...base, + resourceLimits: { ...base.resourceLimits, maxRows: base.rowCountScanned - 1 }, + }), + { accepted: false, code: 'INVALID_COUNT' }, + ); }); From 3311f2a9be6598487e2c9883af3b624258d96f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:42:20 +0700 Subject: [PATCH 21/30] fix(sa): support complete XLSX row geometry --- packages/domain/src/spreadsheet-audit/v1.ts | 2 +- packages/domain/test/spreadsheet-audit-v1.test.mjs | 7 +++++++ services/api/openapi/v1.json | 2 +- .../api/src/features/sa/api/spreadsheet-audit.dto.ts | 4 ++-- services/api/test/openapi.test.ts | 9 +++++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts index bde1fb31..a09feaea 100644 --- a/packages/domain/src/spreadsheet-audit/v1.ts +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -119,7 +119,7 @@ function sheet(input: unknown): SpreadsheetAuditSheetV1 | undefined { formulaCount === undefined ) return undefined; - if (maxRow > 1_000_000 || maxColumn > 16_384 || formulaCount > 1_000_000) return undefined; + if (maxRow > 1_048_576 || maxColumn > 16_384 || formulaCount > 1_000_000) return undefined; return Object.freeze({ sheetId, name, maxRow, maxColumn, formulaCount }); } diff --git a/packages/domain/test/spreadsheet-audit-v1.test.mjs b/packages/domain/test/spreadsheet-audit-v1.test.mjs index 4c68ce00..65e9b100 100644 --- a/packages/domain/test/spreadsheet-audit-v1.test.mjs +++ b/packages/domain/test/spreadsheet-audit-v1.test.mjs @@ -70,6 +70,13 @@ void test('[SA-006] findings must stay inside the exact sheet geometry', () => { }), { accepted: false, code: 'INVALID_COORDINATE' }, ); + assert.equal( + createSpreadsheetAuditResultV1({ + ...base, + sheets: [{ ...base.sheets[0], maxRow: 1_048_576 }], + }).accepted, + true, + ); }); void test('[SA-004] finding validation preserves structural error codes', () => { diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index d0abe932..bda203ac 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -8372,7 +8372,7 @@ "properties": { "sheetId": { "type": "string", "format": "uuid" }, "name": { "type": "string", "maxLength": 128 }, - "maxRow": { "type": "number", "minimum": 0, "maximum": 1000000 }, + "maxRow": { "type": "number", "minimum": 0, "maximum": 1048576 }, "maxColumn": { "type": "number", "minimum": 0, "maximum": 16384 }, "formulaCount": { "type": "number", "minimum": 0, "maximum": 1000000 } }, diff --git a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts index 5d8bfd12..bae916fe 100644 --- a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts +++ b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts @@ -29,10 +29,10 @@ export class SpreadsheetAuditSheetDto { @MaxLength(128) name!: string; - @ApiProperty({ minimum: 0, maximum: 1_000_000 }) + @ApiProperty({ minimum: 0, maximum: 1_048_576 }) @IsInt() @Min(0) - @Max(1_000_000) + @Max(1_048_576) maxRow!: number; @ApiProperty({ minimum: 0, maximum: 16_384 }) diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 4af56f15..909b811b 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -200,6 +200,15 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, const property = (schema['properties'] as Record>)[propertyName]; assert.equal(property?.['maxItems'], maxItems, `${schemaName}.${propertyName} must be bounded`); } + const spreadsheetSheet = firstDocument.components?.schemas?.[ + 'SpreadsheetAuditSheetDto' + ] as Record; + assert.equal( + (spreadsheetSheet['properties'] as Record>)['maxRow']?.[ + 'maximum' + ], + 1_048_576, + ); for (const operation of operations(firstDocument)) { const headerNames = (operation.parameters ?? []) From 4a4c781e87df39e11017f490d4e32142d3d6f16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:42:47 +0700 Subject: [PATCH 22/30] refactor(iae): simplify inbox revision context --- services/api/src/features/iae/api/inbox.controller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/api/src/features/iae/api/inbox.controller.ts b/services/api/src/features/iae/api/inbox.controller.ts index 5f627ecb..a6284cca 100644 --- a/services/api/src/features/iae/api/inbox.controller.ts +++ b/services/api/src/features/iae/api/inbox.controller.ts @@ -88,8 +88,7 @@ export class InboxController { 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 }); + const mutationContext = 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 } : {}), From 34495ddd3ba0aafdfdd6c95b727de79b565e12c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:43:46 +0700 Subject: [PATCH 23/30] fix(iae): harden export manifest persistence --- ...isma-artifact-export-repository.adapter.ts | 20 ++++++++- .../prisma-artifact-export-repository.test.ts | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) 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 index 1fb55e4b..3f6f9b05 100644 --- 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 @@ -93,6 +93,15 @@ function visible(context: TenantScopeV1, row: ArtifactExportDatabaseRowV1): bool return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); } +function isUniqueConstraintViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'P2002' + ); +} + class PrismaArtifactExportTransactionAdapter implements ArtifactExportTransactionPortV1 { public constructor(private readonly client: ArtifactExportDatabaseClientV1) {} @@ -106,12 +115,19 @@ class PrismaArtifactExportTransactionAdapter implements ArtifactExportTransactio where: { id: manifest.manifestId }, }); if (existing !== null) { + if (!visible(context.tenantScope, existing)) + throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); 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) }); + try { + await this.client.artifactExportManifestRecord.create({ data: domainToCreate(manifest) }); + } catch (error) { + if (isUniqueConstraintViolation(error)) throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); + throw error; + } } public async find( @@ -142,7 +158,7 @@ export class PrismaArtifactExportRepositoryAdapter implements ArtifactExportRepo } public save(context: IamTenantContextV1, manifest: ArtifactExportManifestV1): Promise { - return new PrismaArtifactExportTransactionAdapter(this.client).save(context, manifest); + return this.withTransaction(context, (transaction) => transaction.save(context, manifest)); } public find( 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 3e03102f..d2820d45 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 @@ -49,6 +49,7 @@ if (!manifest.accepted) throw new Error('fixture manifest invalid'); void test('IAE-018 Prisma export adapter preserves immutable manifests and scopes reads', async () => { const rows = new Map(); + let transactions = 0; const client: ArtifactExportDatabaseClientV1 = { artifactExportManifestRecord: { create({ data }) { @@ -64,6 +65,7 @@ void test('IAE-018 Prisma export adapter preserves immutable manifests and scope }, }, $transaction(work) { + transactions += 1; return work(client); }, }; @@ -72,4 +74,43 @@ void test('IAE-018 Prisma export adapter preserves immutable manifests and scope await repository.save(context, manifest.value); assert.deepEqual(await repository.find(context, manifest.value.manifestId), manifest.value); assert.equal(rows.size, 1); + assert.equal(transactions, 2); +}); + +void test('IAE-018 Prisma export adapter hides colliding tenants and translates create races', async () => { + const hiddenRow: ArtifactExportDatabaseRowV1 = { + id: manifest.value.manifestId, + scopeType: 'workspace', + organizationId: '77777777-7777-4777-8777-777777777777', + workspaceId: '88888888-8888-4888-8888-888888888888', + projectId: null, + entries: 'must-not-be-parsed', + approvalState: 'PENDING', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + canonicalHash: 'b'.repeat(64), + }; + const hiddenClient: ArtifactExportDatabaseClientV1 = { + artifactExportManifestRecord: { + create: () => Promise.reject(new Error('unexpected create')), + findUnique: () => Promise.resolve(hiddenRow), + }, + $transaction: (work) => work(hiddenClient), + }; + await assert.rejects( + new PrismaArtifactExportRepositoryAdapter(hiddenClient).save(context, manifest.value), + /IAE_IMMUTABLE_EXPORT_MANIFEST/u, + ); + + const raceClient: ArtifactExportDatabaseClientV1 = { + artifactExportManifestRecord: { + create: () => + Promise.reject(Object.assign(new Error('unique constraint violation'), { code: 'P2002' })), + findUnique: () => Promise.resolve(null), + }, + $transaction: (work) => work(raceClient), + }; + await assert.rejects( + new PrismaArtifactExportRepositoryAdapter(raceClient).save(context, manifest.value), + /IAE_IMMUTABLE_EXPORT_MANIFEST/u, + ); }); From 5c0b1d431e5db7914551ff640126269c59c7c9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:45:14 +0700 Subject: [PATCH 24/30] fix(api): map unavailable tenant context safely --- .../src/platform/http/problem-details.filter.ts | 2 +- .../http/request-tenant-context.port.ts | 14 +++++++++++++- .../http/session-tenant-context.adapter.ts | 17 ++++------------- services/api/test/http-contract.test.ts | 7 +++++++ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 41c82576..a4597b8e 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -13,7 +13,7 @@ import { MfaProblemError } from '../../features/iam/application/mfa-problem.erro import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; import { AuditProblemError } from '../../features/aud/application/audit-problem.error.js'; -import { RequestTenantContextProblemError } from './session-tenant-context.adapter.js'; +import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; import { createProblem, type ProblemInput } from './problem-details.js'; diff --git a/services/api/src/platform/http/request-tenant-context.port.ts b/services/api/src/platform/http/request-tenant-context.port.ts index 05ae6ade..53b3a9a8 100644 --- a/services/api/src/platform/http/request-tenant-context.port.ts +++ b/services/api/src/platform/http/request-tenant-context.port.ts @@ -2,6 +2,18 @@ import type { IamTenantContextV1 } from '../../features/iam/application/tenant-c export const REQUEST_TENANT_CONTEXT = Symbol('REQUEST_TENANT_CONTEXT'); +export type RequestTenantContextProblemCodeV1 = + | 'AUTHENTICATION_FAILED' + | 'AUTHENTICATION_UNAVAILABLE' + | 'CONTEXT_INVALID'; + +export class RequestTenantContextProblemError extends Error { + constructor(readonly code: RequestTenantContextProblemCodeV1) { + super(code); + this.name = 'RequestTenantContextProblemError'; + } +} + /** Resolves an already-authenticated request to a scoped IAM context. */ export interface RequestTenantContextPortV1 { resolve(request: unknown): Promise; @@ -12,6 +24,6 @@ export class UnavailableRequestTenantContextAdapter implements RequestTenantCont public async resolve(request: unknown): Promise { void request; await Promise.resolve(); - throw new Error('AUTHENTICATED_CONTEXT_UNAVAILABLE'); + throw new RequestTenantContextProblemError('AUTHENTICATION_UNAVAILABLE'); } } diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index a0852f39..00199e9b 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -2,21 +2,12 @@ import { randomUUID } from 'node:crypto'; import { type AuthenticatedPrincipalV1 } from '../../features/iam/application/authentication.port.js'; import { createIamTenantContextV1 } from '../../features/iam/application/tenant-context.js'; -import type { RequestTenantContextPortV1 } from './request-tenant-context.port.js'; +import { + RequestTenantContextProblemError, + type RequestTenantContextPortV1, +} from './request-tenant-context.port.js'; import { getRequestContext } from './request-context.js'; -export type RequestTenantContextProblemCodeV1 = - | 'AUTHENTICATION_FAILED' - | 'AUTHENTICATION_UNAVAILABLE' - | 'CONTEXT_INVALID'; - -export class RequestTenantContextProblemError extends Error { - constructor(readonly code: RequestTenantContextProblemCodeV1) { - super(code); - this.name = 'RequestTenantContextProblemError'; - } -} - type HeaderValueV1 = string | readonly string[] | undefined; const SAFE_METHODS_V1 = new Set(['GET', 'HEAD', 'OPTIONS']); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 364c38cf..1b6ef295 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -83,6 +83,13 @@ void test('reports ready only through the injectable readiness port and minimize ); }); +void test('maps an unconfigured tenant context provider to authentication unavailability', async () => { + await withApp({}, async (app) => { + const response = await app.inject({ method: 'GET', url: '/v1/artifacts/inbox' }); + assertProblem(response, 503, 'AUTHENTICATION_UNAVAILABLE'); + }); +}); + void test('propagates one valid correlation UUID while generating a distinct request UUID', async () => { await withApp({}, async (app) => { const response = await app.inject({ From b6603eb0e2761f16f99022224b3b5fa96a53dd6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:46:00 +0700 Subject: [PATCH 25/30] fix(iae): require integer admission byte sizes --- services/api/openapi/v1.json | 2 +- .../src/features/iae/api/artifact-admission.dto.ts | 5 ++--- .../iae/artifact-admission.controller.test.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index bda203ac..16922a66 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -7646,7 +7646,7 @@ "type": "object", "properties": { "actualSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "actualByteSize": { "type": "number", "minimum": 0 }, + "actualByteSize": { "type": "integer", "minimum": 0 }, "detectedMediaType": { "type": "string" }, "scanState": { "type": "string", "enum": ["PENDING", "CLEAN", "MALICIOUS", "FAILED"] }, "maxByteSize": { "type": "number", "minimum": 0 }, diff --git a/services/api/src/features/iae/api/artifact-admission.dto.ts b/services/api/src/features/iae/api/artifact-admission.dto.ts index c79647e2..36751eba 100644 --- a/services/api/src/features/iae/api/artifact-admission.dto.ts +++ b/services/api/src/features/iae/api/artifact-admission.dto.ts @@ -3,7 +3,6 @@ import { IsISO8601, IsIn, IsInt, - IsNumber, IsOptional, IsString, Min, @@ -15,8 +14,8 @@ export class AdmitArtifactDto { @Matches(/^[0-9a-f]{64}$/u) actualSha256!: string; - @ApiProperty({ minimum: 0 }) - @IsNumber() + @ApiProperty({ type: 'integer', minimum: 0 }) + @IsInt() @Min(0) actualByteSize!: number; 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 6dae83c3..a0380e0e 100644 --- a/services/api/test/features/iae/artifact-admission.controller.test.ts +++ b/services/api/test/features/iae/artifact-admission.controller.test.ts @@ -47,6 +47,19 @@ void test('IAE-009/010 admission HTTP endpoint persists clean status without sou requestTenantContext, }); try { + const fractional = await app.inject({ + method: 'POST', + url: `/v1/artifact-versions/${artifact.value.versionId}/admit`, + payload: { + actualSha256: 'a'.repeat(64), + actualByteSize: 4.5, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }, + }); + assert.equal(fractional.statusCode, 400); + const response = await app.inject({ method: 'POST', url: `/v1/artifact-versions/${artifact.value.versionId}/admit`, From c59b5b0e1412725175d5255cfd01369a539b00ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:47:35 +0700 Subject: [PATCH 26/30] fix(iae): map export rejections to HTTP problems --- .../iae/api/artifact-export.controller.ts | 9 ++- .../artifact-export-problem.error.ts | 10 ++++ .../platform/http/problem-details.filter.ts | 13 ++++ .../iae/artifact-export.controller.test.ts | 59 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/iae/application/artifact-export-problem.error.ts create mode 100644 services/api/test/features/iae/artifact-export.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-export.controller.ts b/services/api/src/features/iae/api/artifact-export.controller.ts index c1a44bfb..f03f17e5 100644 --- a/services/api/src/features/iae/api/artifact-export.controller.ts +++ b/services/api/src/features/iae/api/artifact-export.controller.ts @@ -15,6 +15,7 @@ import { type ArtifactExportRepositoryPortV1, } from '../application/artifact-export-repository.port.js'; import { ArtifactExportService } from '../application/artifact-export.service.js'; +import { ArtifactExportProblemError } from '../application/artifact-export-problem.error.js'; import { CreateArtifactExportDto } from './artifact-export.dto.js'; import { REQUEST_TENANT_CONTEXT, @@ -41,13 +42,17 @@ export class ArtifactExportController { @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); + const result = await this.exports.create(context, input); + if (!result.accepted) throw new ArtifactExportProblemError(result.code); + return result; } @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); + const result = await this.exports.find(context, manifestId); + if (!result.accepted) throw new ArtifactExportProblemError(result.code); + return result; } } diff --git a/services/api/src/features/iae/application/artifact-export-problem.error.ts b/services/api/src/features/iae/application/artifact-export-problem.error.ts new file mode 100644 index 00000000..b6ca8624 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-export-problem.error.ts @@ -0,0 +1,10 @@ +import type { ArtifactExportErrorCodeV1 } from '@databreeze/domain/artifact-export/v1'; + +export type ArtifactExportProblemCodeV1 = ArtifactExportErrorCodeV1 | 'ARTIFACT_NOT_FOUND'; + +export class ArtifactExportProblemError extends Error { + public constructor(readonly code: ArtifactExportProblemCodeV1) { + super(code); + this.name = 'ArtifactExportProblemError'; + } +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index a4597b8e..febe07a1 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -13,6 +13,7 @@ import { MfaProblemError } from '../../features/iam/application/mfa-problem.erro import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; import { AuditProblemError } from '../../features/aud/application/audit-problem.error.js'; +import { ArtifactExportProblemError } from '../../features/iae/application/artifact-export-problem.error.js'; import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; @@ -105,6 +106,18 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: HttpStatus.SERVICE_UNAVAILABLE, }; } + if (error instanceof ArtifactExportProblemError) { + const notFound = error.code === 'ARTIFACT_NOT_FOUND'; + return { + code: error.code, + correlationId, + messageKey: notFound + ? 'api.error.artifact_export_not_found' + : 'api.error.artifact_export_invalid', + retryable: false, + status: notFound ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; const unavailable = error.code === 'AUTHENTICATION_UNAVAILABLE'; diff --git a/services/api/test/features/iae/artifact-export.controller.test.ts b/services/api/test/features/iae/artifact-export.controller.test.ts new file mode 100644 index 00000000..8662d6ea --- /dev/null +++ b/services/api/test/features/iae/artifact-export.controller.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +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 { 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: 'artifact-export-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +void test('IAE-018 export HTTP maps rejected service outcomes to problem responses', async () => { + const { app } = await createApiApplication({ + artifactExportRepository: new InMemoryArtifactExportRepositoryAdapter(), + artifactLineageRepository: new InMemoryArtifactLineageRepositoryAdapter(), + artifactRepository: new InMemoryArtifactRepositoryAdapter(), + requestTenantContext: { resolve: () => Promise.resolve(context) }, + }); + try { + const invalid = await app.inject({ method: 'GET', url: '/v1/artifacts/exports/not-a-uuid' }); + assert.equal(invalid.statusCode, 400); + assert.match(String(invalid.headers['content-type']), /^application\/problem\+json/u); + assert.equal((invalid.json() as { code: string }).code, 'INVALID_IDENTIFIER'); + + const missing = await app.inject({ + method: 'GET', + url: '/v1/artifacts/exports/55555555-5555-4555-8555-555555555555', + }); + assert.equal(missing.statusCode, 404); + assert.equal((missing.json() as { code: string }).code, 'ARTIFACT_NOT_FOUND'); + + const missingSource = await app.inject({ + method: 'POST', + url: '/v1/artifacts/exports', + payload: { + manifestId: '66666666-6666-4666-8666-666666666666', + versionIds: ['77777777-7777-4777-8777-777777777777'], + approvalState: 'PENDING', + createdAt: '2026-08-04T00:00:00.000Z', + }, + }); + assert.equal(missingSource.statusCode, 404); + assert.equal((missingSource.json() as { code: string }).code, 'ARTIFACT_NOT_FOUND'); + } finally { + await app.close(); + } +}); From ea3c4ed3aa3abf3980b7b7e21c8a2e90638eb513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:48:52 +0700 Subject: [PATCH 27/30] fix(iae): require strict UTC governance dates --- .../iae/api/artifact-retention.dto.ts | 25 +++++++++++++------ .../src/features/iae/api/inbox-item.dto.ts | 4 ++- .../iae/artifact-retention.controller.test.ts | 18 +++++++++++++ .../features/iae/inbox.controller.test.ts | 16 ++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/services/api/src/features/iae/api/artifact-retention.dto.ts b/services/api/src/features/iae/api/artifact-retention.dto.ts index be4ed98c..d1ca10fc 100644 --- a/services/api/src/features/iae/api/artifact-retention.dto.ts +++ b/services/api/src/features/iae/api/artifact-retention.dto.ts @@ -1,25 +1,32 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsISO8601, IsInt, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsISO8601, IsInt, IsUUID, Matches, Min } from 'class-validator'; + +const strictUtcTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; export class RetentionEvaluationDto { @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(strictUtcTimestamp) evaluatedAt!: string; @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(strictUtcTimestamp) workspaceRetentionUntil!: string; @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(strictUtcTimestamp) resourceRetentionUntil!: string; @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(strictUtcTimestamp) auditRetentionUntil!: string; @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(strictUtcTimestamp) recoveryWindowUntil!: string; @ApiProperty() @@ -41,13 +48,15 @@ export class CreateArtifactDeletionRequestDto extends RetentionEvaluationDto { requestedBy!: string; @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(strictUtcTimestamp) requestedAt!: string; } export class AuthorizeArtifactDeletionRequestDto extends RetentionEvaluationDto { @ApiProperty({ format: 'date-time' }) - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(strictUtcTimestamp) approvedAt!: string; @ApiProperty() 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 011d826a..44d7713a 100644 --- a/services/api/src/features/iae/api/inbox-item.dto.ts +++ b/services/api/src/features/iae/api/inbox-item.dto.ts @@ -9,6 +9,7 @@ import { IsOptional, IsString, IsUUID, + Matches, MaxLength, Min, MinLength, @@ -65,7 +66,8 @@ export class UpdateInboxMetadataDto { required: false, }) @IsOptional() - @IsISO8601() + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) dueAt?: string | null; @ApiProperty({ minimum: 1, required: false }) diff --git a/services/api/test/features/iae/artifact-retention.controller.test.ts b/services/api/test/features/iae/artifact-retention.controller.test.ts index 7492de79..cd965517 100644 --- a/services/api/test/features/iae/artifact-retention.controller.test.ts +++ b/services/api/test/features/iae/artifact-retention.controller.test.ts @@ -59,6 +59,24 @@ void test('[IAE-016, IAM-009] retention HTTP binds requester to the authenticate requestTenantContext, }); try { + const nonUtc = await app.inject({ + method: 'POST', + url: `/v1/artifact-versions/${versionId}/deletion-requests`, + payload: { + requestId, + requestedBy: actorId, + requestedAt: '2026-08-02T08:00:00.000+07:00', + evaluatedAt: '2026-08-02T01:00:00.000Z', + workspaceRetentionUntil: '2026-07-01T00:00:00.000Z', + resourceRetentionUntil: '2026-07-01T00:00:00.000Z', + auditRetentionUntil: '2026-07-01T00:00:00.000Z', + recoveryWindowUntil: '2026-07-01T00:00:00.000Z', + activeApproval: false, + legalHold: false, + }, + }); + assert.equal(nonUtc.statusCode, 400); + const response = await app.inject({ method: 'POST', url: `/v1/artifact-versions/${versionId}/deletion-requests`, diff --git a/services/api/test/features/iae/inbox.controller.test.ts b/services/api/test/features/iae/inbox.controller.test.ts index f0768333..8f7d3db4 100644 --- a/services/api/test/features/iae/inbox.controller.test.ts +++ b/services/api/test/features/iae/inbox.controller.test.ts @@ -104,6 +104,22 @@ void test('[IAE-013] HTTP inbox metadata patch uses a revision precondition and 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/iu); + + const nonUtc = await app.inject({ + method: 'PATCH', + url: `/v1/artifacts/inbox/${inboxItemId}`, + headers: { 'if-match': '2' }, + payload: { dueAt: '2026-01-02T07:00:00.000+07:00' }, + }); + assert.equal(nonUtc.statusCode, 400); + + const cleared = await app.inject({ + method: 'PATCH', + url: `/v1/artifacts/inbox/${inboxItemId}`, + headers: { 'if-match': '2' }, + payload: { dueAt: null }, + }); + assert.equal(cleared.statusCode, 200); } finally { await app.close(); } From bc9e2665098b920413178bc4cc9901ac9b199953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:50:04 +0700 Subject: [PATCH 28/30] fix(dsm): translate immutable create races --- .../prisma-dataset-profile-repository.adapter.ts | 9 ++++++++- .../prisma-dataset-quality-repository.adapter.ts | 9 ++++++++- .../prisma-dataset-version-repository.adapter.ts | 9 ++++++++- services/api/src/features/dsm/adapter/prisma-error.ts | 8 ++++++++ .../dsm/prisma-dataset-profile-repository.test.ts | 11 ++++++++++- .../dsm/prisma-dataset-quality-repository.test.ts | 11 ++++++++++- .../dsm/prisma-dataset-version-repository.test.ts | 11 ++++++++++- 7 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 services/api/src/features/dsm/adapter/prisma-error.ts 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 index f5f8c195..1c8bce64 100644 --- 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 @@ -13,6 +13,7 @@ import type { DatasetProfileRepositoryPortV1, DatasetProfileTransactionPortV1, } from '../application/dataset-profile-repository.port.js'; +import { isPrismaUniqueConstraintViolationV1 } from './prisma-error.js'; export interface DatasetProfileDatabaseRowV1 { readonly id: string; @@ -155,7 +156,13 @@ class PrismaDatasetProfileTransactionAdapter implements DatasetProfileTransactio throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); return; } - await this.client.datasetProfileRecord.create({ data: domainToCreate(profile) }); + try { + await this.client.datasetProfileRecord.create({ data: domainToCreate(profile) }); + } catch (error) { + if (isPrismaUniqueConstraintViolationV1(error)) + throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); + throw error; + } } public async find( 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 index b3becaec..a7a63425 100644 --- 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 @@ -13,6 +13,7 @@ import type { DatasetQualityRepositoryPortV1, DatasetQualityTransactionPortV1, } from '../application/dataset-quality-repository.port.js'; +import { isPrismaUniqueConstraintViolationV1 } from './prisma-error.js'; export interface DatasetQualityDatabaseRowV1 { readonly id: string; @@ -129,7 +130,13 @@ class PrismaDatasetQualityTransactionAdapter implements DatasetQualityTransactio throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); return; } - await this.client.datasetQualityResultRecord.create({ data: domainToCreate(result) }); + try { + await this.client.datasetQualityResultRecord.create({ data: domainToCreate(result) }); + } catch (error) { + if (isPrismaUniqueConstraintViolationV1(error)) + throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); + throw error; + } } public async find( 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 55cb6c03..e9c3bed1 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 @@ -13,6 +13,7 @@ import type { DatasetVersionRepositoryPortV1, DatasetVersionTransactionPortV1, } from '../application/dataset-version-repository.port.js'; +import { isPrismaUniqueConstraintViolationV1 } from './prisma-error.js'; export interface DatasetVersionDatabaseRowV1 { readonly id: string; @@ -133,7 +134,13 @@ class PrismaDatasetVersionTransactionAdapter implements DatasetVersionTransactio throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); return; } - await this.client.datasetVersionRecord.create({ data: domainToCreate(version) }); + try { + await this.client.datasetVersionRecord.create({ data: domainToCreate(version) }); + } catch (error) { + if (isPrismaUniqueConstraintViolationV1(error)) + throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); + throw error; + } } public async find( diff --git a/services/api/src/features/dsm/adapter/prisma-error.ts b/services/api/src/features/dsm/adapter/prisma-error.ts new file mode 100644 index 00000000..61e6c184 --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-error.ts @@ -0,0 +1,8 @@ +export function isPrismaUniqueConstraintViolationV1(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'P2002' + ); +} 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 index 2488ab5d..45ae8ad8 100644 --- a/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts +++ b/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts @@ -37,10 +37,15 @@ function context() { return result.value; } -function client(rows: DatasetProfileDatabaseRowV1[]): DatasetProfileDatabaseClientV1 { +function client( + rows: DatasetProfileDatabaseRowV1[], + createConflict = false, +): DatasetProfileDatabaseClientV1 { return { datasetProfileRecord: { create({ data }) { + if (createConflict) + throw Object.assign(new Error('unique constraint violation'), { code: 'P2002' }); const persisted = { ...data } as DatasetProfileDatabaseRowV1; rows.push(persisted); return Promise.resolve(persisted); @@ -93,4 +98,8 @@ void test('[DSM-011, IAM-009] Prisma profile adapter persists immutable disclosu created.value, ]); assert.equal(rows.length, 1); + await assert.rejects( + new PrismaDatasetProfileRepositoryAdapter(client([], true)).save(tenantContext, created.value), + /DSM_IMMUTABLE_DATASET_PROFILE/u, + ); }); 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 index 00c82ead..b53840ee 100644 --- a/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts +++ b/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts @@ -37,10 +37,15 @@ function context() { return result.value; } -function client(rows: DatasetQualityDatabaseRowV1[]): DatasetQualityDatabaseClientV1 { +function client( + rows: DatasetQualityDatabaseRowV1[], + createConflict = false, +): DatasetQualityDatabaseClientV1 { return { datasetQualityResultRecord: { create({ data }) { + if (createConflict) + throw Object.assign(new Error('unique constraint violation'), { code: 'P2002' }); const persisted = { ...data } as DatasetQualityDatabaseRowV1; rows.push(persisted); return Promise.resolve(persisted); @@ -92,4 +97,8 @@ void test('[DSM-011, DSM-013, IAM-009] Prisma quality adapter persists immutable created.value, ]); assert.equal(rows.length, 1); + await assert.rejects( + new PrismaDatasetQualityRepositoryAdapter(client([], true)).save(tenantContext, created.value), + /DSM_IMMUTABLE_QUALITY_RESULT/u, + ); }); 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 89fb7262..e76b5da4 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 @@ -37,10 +37,15 @@ function context() { return result.value; } -function client(rows: DatasetVersionDatabaseRowV1[]): DatasetVersionDatabaseClientV1 { +function client( + rows: DatasetVersionDatabaseRowV1[], + createConflict = false, +): DatasetVersionDatabaseClientV1 { return { datasetVersionRecord: { create({ data }) { + if (createConflict) + throw Object.assign(new Error('unique constraint violation'), { code: 'P2002' }); const persisted = { ...data } as DatasetVersionDatabaseRowV1; rows.push(persisted); return Promise.resolve(persisted); @@ -91,4 +96,8 @@ void test('[DSM-002, DSM-003, IAM-009] Prisma dataset version adapter is immutab 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); + await assert.rejects( + new PrismaDatasetVersionRepositoryAdapter(client([], true)).save(tenantContext, created.value), + /DSM_IMMUTABLE_DATASET_VERSION/u, + ); }); From e634d6553555fc879c4340a4b1836bdbfa9dbfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:51:27 +0700 Subject: [PATCH 29/30] fix(dsm): constrain quality values to scalars --- .../features/dsm/api/dataset-quality.dto.ts | 17 +++++++++-- .../dsm/dataset-quality.controller.test.ts | 29 +++++++++++++++++++ .../iae/artifact-export.controller.test.ts | 11 +++++-- 3 files changed, 52 insertions(+), 5 deletions(-) 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 91cc51d1..3d6032b3 100644 --- a/services/api/src/features/dsm/api/dataset-quality.dto.ts +++ b/services/api/src/features/dsm/api/dataset-quality.dto.ts @@ -2,7 +2,6 @@ import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, - Allow, IsArray, IsIn, IsInt, @@ -15,8 +14,22 @@ import { Min, MinLength, ValidateNested, + Validate, + ValidatorConstraint, + type ValidatorConstraintInterface, } from 'class-validator'; +@ValidatorConstraint({ name: 'isDatasetQualityScalar', async: false }) +class DatasetQualityScalarConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ); + } +} + export class DatasetQualitySafeValueDto { @ApiProperty({ enum: [ @@ -55,7 +68,7 @@ export class DatasetQualitySafeValueDto { oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], }) @IsOptional() - @Allow() + @Validate(DatasetQualityScalarConstraint) value?: string | number | boolean; } 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 8d090ccf..5225478b 100644 --- a/services/api/test/features/dsm/dataset-quality.controller.test.ts +++ b/services/api/test/features/dsm/dataset-quality.controller.test.ts @@ -111,6 +111,35 @@ void test('[DSM-013] quality DTO rejects unsupported source-bearing fields and m }, }); assert.equal(response.statusCode, 400); + + const nestedValue = 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: 1, + qualityState: 'BLOCKED', + findings: [ + { + findingId: '00000000-0000-4000-8000-000000000929', + ruleId: '00000000-0000-4000-8000-000000000930', + severity: 'ERROR', + messageCode: 'INVALID_VALUE', + occurrenceCount: 1, + evidenceIds: [], + detailHash: 'b'.repeat(64), + actual: { kind: 'TEXT', value: { source: 'must-not-be-accepted' } }, + }, + ], + resultFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(nestedValue.statusCode, 400); } finally { await app.close(); } diff --git a/services/api/test/features/iae/artifact-export.controller.test.ts b/services/api/test/features/iae/artifact-export.controller.test.ts index 8662d6ea..63c9c7e4 100644 --- a/services/api/test/features/iae/artifact-export.controller.test.ts +++ b/services/api/test/features/iae/artifact-export.controller.test.ts @@ -21,6 +21,11 @@ const contextResult = createIamTenantContextV1({ if (!contextResult.accepted) throw new Error('fixture context invalid'); const context = contextResult.value; +function problemCode(body: string): unknown { + const parsed: unknown = JSON.parse(body); + return typeof parsed === 'object' && parsed !== null && 'code' in parsed ? parsed.code : undefined; +} + void test('IAE-018 export HTTP maps rejected service outcomes to problem responses', async () => { const { app } = await createApiApplication({ artifactExportRepository: new InMemoryArtifactExportRepositoryAdapter(), @@ -32,14 +37,14 @@ void test('IAE-018 export HTTP maps rejected service outcomes to problem respons const invalid = await app.inject({ method: 'GET', url: '/v1/artifacts/exports/not-a-uuid' }); assert.equal(invalid.statusCode, 400); assert.match(String(invalid.headers['content-type']), /^application\/problem\+json/u); - assert.equal((invalid.json() as { code: string }).code, 'INVALID_IDENTIFIER'); + assert.equal(problemCode(invalid.body), 'INVALID_IDENTIFIER'); const missing = await app.inject({ method: 'GET', url: '/v1/artifacts/exports/55555555-5555-4555-8555-555555555555', }); assert.equal(missing.statusCode, 404); - assert.equal((missing.json() as { code: string }).code, 'ARTIFACT_NOT_FOUND'); + assert.equal(problemCode(missing.body), 'ARTIFACT_NOT_FOUND'); const missingSource = await app.inject({ method: 'POST', @@ -52,7 +57,7 @@ void test('IAE-018 export HTTP maps rejected service outcomes to problem respons }, }); assert.equal(missingSource.statusCode, 404); - assert.equal((missingSource.json() as { code: string }).code, 'ARTIFACT_NOT_FOUND'); + assert.equal(problemCode(missingSource.body), 'ARTIFACT_NOT_FOUND'); } finally { await app.close(); } From 83fbeec557eb3444c6a81b100820370efd24019c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 16:54:03 +0700 Subject: [PATCH 30/30] docs(review): record PR 31 dispositions --- .../coderabbit-pr-31-disposition.md | 50 +++++++++++++++++++ packages/domain/src/artifact-upload/v1.ts | 3 +- packages/domain/src/spreadsheet-audit/v1.ts | 3 +- ...isma-artifact-export-repository.adapter.ts | 3 +- .../iae/api/artifact-admission.dto.ts | 10 +--- .../iae/artifact-admission.service.test.ts | 5 +- .../iae/artifact-export.controller.test.ts | 4 +- services/api/test/openapi.test.ts | 10 +++- 8 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 docs/operations/coderabbit-pr-31-disposition.md diff --git a/docs/operations/coderabbit-pr-31-disposition.md b/docs/operations/coderabbit-pr-31-disposition.md new file mode 100644 index 00000000..2c2715ec --- /dev/null +++ b/docs/operations/coderabbit-pr-31-disposition.md @@ -0,0 +1,50 @@ +# CodeRabbit PR 31 Disposition + +Date: 2026-08-03 +Promotion PR: [#31](https://github.com/DatabreezeService/databreeze-platform/pull/31) +Automatic review ID: `4842552845` +Reviewed range: `8695eed4bd5b988af9f4bea17e724ef5e1ac101d..688896af0af45281f8d9f379837d95abed04ac6c` + +CodeRabbit ran once automatically on the promotion PR. No manual review or rerun was requested. All 32 code findings were reproduced against the current `dev` state: 29 were accepted and fixed with regression coverage, and 3 were rejected after checking the later invariants and public result types. + +| ID | Finding | Disposition | Evidence | +|---|---|---|---| +| I-01 | Request input could replace the repository-loaded artifact during admission. | Accepted and fixed. The trusted artifact is applied last and a runtime-key injection regression test proves the stored version remains authoritative. | `68e0e4a` | +| I-02 | XLSX XML members were fully decompressed before the size check. | Accepted and fixed. XML members now use a bounded `ZipExtFile` read and tests reject use of unbounded `ZipFile.read`. | `069c0cd` | +| O-01 | Prisma intake and export fixtures accepted duplicate primary keys. | Accepted and fixed. Both fixtures now emulate Prisma `P2002` behavior. | `718b406` | +| M-01 | Sparse quality `stateCounts` could raise `KeyError`. | Accepted and fixed with zero defaults and a sparse-profile regression test. | `5ed2cb4` | +| M-02 | Spreadsheet `blockedReasons` accepted duplicates. | Accepted and fixed with `ArrayUnique`. | `96553d0` | +| M-03 | A direct in-memory spreadsheet-audit save could be discarded by transaction rollback. | Accepted and fixed. Public saves use the transaction queue and callbacks use unwrapped helpers. | `6d67783` | +| M-04 | Spreadsheet-audit `createdAt` accepted non-UTC timestamps. | Accepted and fixed with strict ISO validation and an uppercase-`Z` timestamp pattern. | `8b31681` | +| M-05 | Several request arrays lacked matching runtime and OpenAPI bounds. | Accepted and fixed for version IDs, fields, mapping steps, rules, artifact inputs, evidence IDs, and findings. | `3bfe600` | +| M-06 | The intake transition test did not verify the persisted revision. | Accepted and fixed. | `fd508f9` | +| M-07 | Inbox content-leak assertions were case-sensitive. | Accepted and fixed. | `812e0c5` | +| M-08 | Readiness 503 responses documented the wrong media type. | Accepted and fixed as `application/problem+json`, with a generated-contract assertion. | `42ff542` | +| M-09 | Expired upload transfer requests were reported as generic storage unavailability. | Accepted and fixed with `UPLOAD_SESSION_EXPIRED`. | `f4af924` | +| M-10 | Export processor-version text was validated before normalization and trimming. | Accepted and fixed; empty normalized text is rejected and valid trimmed text is retained. | `e5c4976` | +| M-11 | Aggregate public API smoke coverage omitted retention and export schema versions. | Accepted and fixed. | `533e7b7` | +| M-12 | The dataset-profile negative test allegedly mixed a sampling error with its count error. | Rejected. `samplingMethod` is required for both completeness modes; the fixture removes only the sample seed when switching to `COMPLETE`, so the first negative case already isolates `INVALID_COUNT`. Clearing `samplingMethod` would create the ambiguity the comment sought to remove. | `packages/domain/test/dataset-profile-v1.test.mjs` | +| M-13 | The spreadsheet value-free test inspected the manifest root rather than the finding. | Accepted and fixed. | `eec8df5` | +| M-14 | Premature upload expiration returned `EXPIRED`. | Accepted and fixed as `INVALID_TIMESTAMP`. | `6173abf` | +| M-15 | Spreadsheet finding parser errors collapsed into `INVALID_COUNT`. | Accepted and fixed. Coordinate, kind, severity, identifier, and hash errors now retain their structural codes. | `3f769e2` | +| M-16 | The upload completion test read `.value` without proving acceptance. | Accepted and fixed. | `adb45ef` | +| M-17 | Premature protected-document expiration returned `EXPIRED`. | Accepted and fixed as `INVALID_STATE`. | `d477368` | +| M-18 | Dataset profiles allowed `rowCountScanned` above `resourceLimits.maxRows`. | Accepted and fixed. | `afb3fdc` | +| M-19 | Spreadsheet `maxRow` stopped below the XLSX row limit. | Accepted and fixed across domain validation, DTO validation, and generated OpenAPI at 1,048,576. | `3311f2a` | +| M-20 | Inbox mutation context contained an unreachable conditional branch. | Accepted and simplified after the existing undefined guard. | `4a4c781` | +| M-21 | Prisma export saves lacked visibility-safe collision handling, transaction-wrapped direct saves, and create-race translation. | Accepted and fixed with tenant-safe checks and stable immutable-manifest errors. | `34495dd` | +| M-22 | Artifact-lineage lookup should use `findMany` to select a visible row. | Rejected against current `dev`. Later commits `68e69df` and `6431c9a` enforce one globally unique lineage per derived version; the unique lookup then checks tenant visibility. `findMany` would weaken that invariant and conceal duplicate persisted state. | `services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts` | +| M-23 | Retention and content-placement service-only error unions omitted domain result codes. | Rejected. `ArtifactRetentionServiceResultV1` already includes `ArtifactRetentionResultV1`, and `ContentPlacementServiceResultV1` already includes `ArtifactResultV1`; both public unions therefore expose the cited codes without duplicating them in their service-only error aliases. | Service result type definitions | +| M-24 | The default request-tenant-context adapter produced a generic 500. | Accepted and fixed. The shared problem error now maps the unconfigured provider to retryable `AUTHENTICATION_UNAVAILABLE`/503. | `5c0b1d4` | +| M-25 | Artifact admission accepted fractional byte sizes at the DTO boundary. | Accepted and fixed with integer runtime validation and OpenAPI type. | `b6603eb` | +| M-26 | Artifact-export controllers returned failed service envelopes with HTTP 200. | Accepted and fixed. Invalid requests map to 400 problems and missing resources to 404 problems. | `c59b5b0` | +| M-27 | Retention and inbox date-time DTOs accepted date-only or offset values. | Accepted and fixed with strict ISO/UTC validation while preserving nullable inbox `dueAt`. | `ea3c4ed` | +| M-28 | DSM immutable repositories leaked Prisma create races. | Accepted and fixed for dataset profiles, quality results, and dataset versions by translating `P2002` into their stable immutable error codes. | `bc9e266` | +| M-29 | Dataset quality safe values accepted objects and arrays despite the scalar OpenAPI contract. | Accepted and fixed with a finite scalar validator and an object-injection regression test. | `e634d65` | + +## Release handling + +- Fixes are applied through a dedicated PR to `dev`; CodeRabbit is not invoked on that PR. +- After the fix PR merges, the two critical inline discussions receive the fixing commit references and the promotion PR receives a link to this disposition. +- PR #31 remains a historical promotion slice. It receives no second CodeRabbit run and is merged only after the repair PR and required checks pass. +- The generic docstring-coverage warning was not treated as a code finding: it did not identify a changed runtime defect, and bulk comments would add noise without improving the reviewed behavior. diff --git a/packages/domain/src/artifact-upload/v1.ts b/packages/domain/src/artifact-upload/v1.ts index 1d72423f..be8120af 100644 --- a/packages/domain/src/artifact-upload/v1.ts +++ b/packages/domain/src/artifact-upload/v1.ts @@ -246,7 +246,8 @@ export function expireArtifactUploadSessionV1( 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('INVALID_TIMESTAMP'); + if (Date.parse(timestampValue) < Date.parse(session.expiresAt)) + return rejected('INVALID_TIMESTAMP'); return accepted( Object.freeze({ ...session, state: 'EXPIRED' as const, revision: session.revision + 1 }), ); diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts index a09feaea..bd1c5ea1 100644 --- a/packages/domain/src/spreadsheet-audit/v1.ts +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -136,8 +136,7 @@ function finding(input: unknown): SpreadsheetAuditResultValidationV1 { diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 909b811b..8d4c061a 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -197,8 +197,14 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, ['RegisterDatasetQualityResultDto', 'findings', 512], ] as const) { const schema = firstDocument.components?.schemas?.[schemaName] as Record; - const property = (schema['properties'] as Record>)[propertyName]; - assert.equal(property?.['maxItems'], maxItems, `${schemaName}.${propertyName} must be bounded`); + const property = (schema['properties'] as Record>)[ + propertyName + ]; + assert.equal( + property?.['maxItems'], + maxItems, + `${schemaName}.${propertyName} must be bounded`, + ); } const spreadsheetSheet = firstDocument.components?.schemas?.[ 'SpreadsheetAuditSheetDto'