From f291c1cf6182d921fdcd41ff9c11fe5d944fe0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:30:00 +0700 Subject: [PATCH 01/44] feat(domain): add immutable intake and dataset governance contracts --- packages/domain/package.json | 12 + packages/domain/src/artifact-intake/v1.ts | 192 +++++++++++ packages/domain/src/dataset-governance/v1.ts | 305 ++++++++++++++++++ packages/domain/src/reference-entity/v1.ts | 208 ++++++++++++ packages/domain/src/v1.ts | 3 + .../domain/test/artifact-intake-v1.test.mjs | 86 +++++ .../domain/test/built-public-api-smoke.mjs | 15 +- .../test/dataset-governance-v1.test.mjs | 96 ++++++ packages/domain/test/public-api-v1.test.mjs | 9 +- .../domain/test/reference-entity-v1.test.mjs | 63 ++++ 10 files changed, 983 insertions(+), 6 deletions(-) create mode 100644 packages/domain/src/artifact-intake/v1.ts create mode 100644 packages/domain/src/dataset-governance/v1.ts create mode 100644 packages/domain/src/reference-entity/v1.ts create mode 100644 packages/domain/test/artifact-intake-v1.test.mjs create mode 100644 packages/domain/test/dataset-governance-v1.test.mjs create mode 100644 packages/domain/test/reference-entity-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 5d8d4701..6f0338eb 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -48,10 +48,18 @@ "types": "./src/artifact/v1.ts", "import": "./dist/artifact/v1.js" }, + "./artifact-intake/v1": { + "types": "./src/artifact-intake/v1.ts", + "import": "./dist/artifact-intake/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" }, + "./dataset-governance/v1": { + "types": "./src/dataset-governance/v1.ts", + "import": "./dist/dataset-governance/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" @@ -79,6 +87,10 @@ "./finding/v1": { "types": "./src/finding/v1.ts", "import": "./dist/finding/v1.js" + }, + "./reference-entity/v1": { + "types": "./src/reference-entity/v1.ts", + "import": "./dist/reference-entity/v1.js" } }, "scripts": { diff --git a/packages/domain/src/artifact-intake/v1.ts b/packages/domain/src/artifact-intake/v1.ts new file mode 100644 index 00000000..86318807 --- /dev/null +++ b/packages/domain/src/artifact-intake/v1.ts @@ -0,0 +1,192 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { ArtifactVersionV1 } from '../artifact/v1.js'; + +/** IAE-001, IAE-009, IAE-010, IAE-013: intake admission is explicit and idempotent. */ +export const ARTIFACT_INTAKE_SCHEMA_VERSION_V1 = 1 as const; + +export type InboxItemStateV1 = + | 'NEW' + | 'ROUTED' + | 'NEEDS_REVIEW' + | 'PROCESSING' + | 'RESOLVED' + | 'QUARANTINED' + | 'ARCHIVED'; +export type ArtifactScanStateV1 = 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; + +export interface InboxItemV1 { + readonly schemaVersion: typeof ARTIFACT_INTAKE_SCHEMA_VERSION_V1; + readonly inboxItemId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly idempotencyKey: string; + readonly artifactVersionId: StableIdentifierV1; + readonly state: InboxItemStateV1; + readonly createdAt: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ArtifactIntakeErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_TEXT' + | 'INVALID_STATE' + | 'INVALID_TRANSITION' + | 'INVALID_HASH' + | 'INVALID_SIZE' + | 'INVALID_MEDIA_TYPE' + | 'DIGEST_MISMATCH' + | 'SIZE_MISMATCH' + | 'MEDIA_MISMATCH' + | 'SIZE_POLICY_EXCEEDED' + | 'SCAN_NOT_COMPLETE'; + +export type ArtifactIntakeResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactIntakeErrorCodeV1 }; + +function accepted(value: TValue): ArtifactIntakeResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactIntakeErrorCodeV1): ArtifactIntakeResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const result = parseTenantScopeV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function mediaType(input: unknown): string | undefined { + return typeof input === 'string' && + /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu.test(input) + ? input.toLowerCase() + : undefined; +} + +const transitions: Readonly> = { + NEW: ['ROUTED', 'NEEDS_REVIEW', 'PROCESSING', 'QUARANTINED'], + ROUTED: ['NEEDS_REVIEW', 'PROCESSING', 'QUARANTINED', 'ARCHIVED'], + NEEDS_REVIEW: ['ROUTED', 'PROCESSING', 'QUARANTINED', 'ARCHIVED'], + PROCESSING: ['RESOLVED', 'NEEDS_REVIEW', 'QUARANTINED'], + RESOLVED: ['ARCHIVED'], + QUARANTINED: ['NEEDS_REVIEW', 'ARCHIVED'], + ARCHIVED: [], +}; + +export function createInboxItemV1(input: { + readonly inboxItemId: unknown; + readonly tenantScope: unknown; + readonly idempotencyKey: unknown; + readonly artifactVersionId: unknown; + readonly createdAt: unknown; +}): ArtifactIntakeResultV1 { + const inboxItemId = identifier(input.inboxItemId); + const tenantScope = scope(input.tenantScope); + const artifactVersionId = identifier(input.artifactVersionId); + const idempotencyKey = text(input.idempotencyKey, 200); + const createdAt = timestamp(input.createdAt); + if (!inboxItemId || !artifactVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!idempotencyKey) return rejected('INVALID_TEXT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_INTAKE_SCHEMA_VERSION_V1, + inboxItemId, + tenantScope, + idempotencyKey, + artifactVersionId, + state: 'NEW' as const, + createdAt, + revision: 1, + }), + ); +} + +export function transitionInboxItemV1( + item: InboxItemV1, + nextStateInput: unknown, +): ArtifactIntakeResultV1 { + if (!Object.hasOwn(transitions, nextStateInput as string)) return rejected('INVALID_STATE'); + const nextState = nextStateInput as InboxItemStateV1; + if (!transitions[item.state].includes(nextState)) return rejected('INVALID_TRANSITION'); + return accepted(Object.freeze({ ...item, state: nextState, revision: item.revision + 1 })); +} + +export function finalizeArtifactAdmissionV1(input: { + readonly artifact: ArtifactVersionV1; + readonly actualSha256: unknown; + readonly actualByteSize: unknown; + readonly detectedMediaType: unknown; + readonly scanState: unknown; + readonly maxByteSize: unknown; +}): ArtifactIntakeResultV1<{ + readonly status: 'ACTIVE' | 'QUARANTINED'; + readonly scanState: ArtifactScanStateV1; +}> { + const actualSha256 = hash(input.actualSha256); + const detectedMediaType = mediaType(input.detectedMediaType); + if (!actualSha256) return rejected('INVALID_HASH'); + if (!detectedMediaType) return rejected('INVALID_MEDIA_TYPE'); + if ( + typeof input.actualByteSize !== 'number' || + !Number.isSafeInteger(input.actualByteSize) || + input.actualByteSize < 0 + ) + return rejected('INVALID_SIZE'); + if ( + typeof input.maxByteSize !== 'number' || + !Number.isSafeInteger(input.maxByteSize) || + input.maxByteSize < 0 + ) + return rejected('INVALID_SIZE'); + if (!['PENDING', 'CLEAN', 'MALICIOUS', 'FAILED'].includes(input.scanState as string)) + return rejected('INVALID_STATE'); + const scanState = input.scanState as ArtifactScanStateV1; + if (actualSha256 !== input.artifact.contentSha256) return rejected('DIGEST_MISMATCH'); + if (input.actualByteSize !== input.artifact.byteSize) return rejected('SIZE_MISMATCH'); + if (detectedMediaType !== input.artifact.mediaType) return rejected('MEDIA_MISMATCH'); + if (input.actualByteSize > input.maxByteSize) return rejected('SIZE_POLICY_EXCEEDED'); + if (scanState === 'PENDING' || scanState === 'FAILED') return rejected('SCAN_NOT_COMPLETE'); + return accepted({ + status: scanState === 'MALICIOUS' ? 'QUARANTINED' : 'ACTIVE', + scanState, + }); +} + +export function intakeScopesEqualV1(left: InboxItemV1, right: InboxItemV1): boolean { + return tenantScopesEqualV1(left.tenantScope, right.tenantScope); +} diff --git a/packages/domain/src/dataset-governance/v1.ts b/packages/domain/src/dataset-governance/v1.ts new file mode 100644 index 00000000..ec87c0fb --- /dev/null +++ b/packages/domain/src/dataset-governance/v1.ts @@ -0,0 +1,305 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-001..DSM-023, DSM-025..DSM-027: governed definitions and reproducible versions. */ +export const DATASET_GOVERNANCE_SCHEMA_VERSION_V1 = 1 as const; + +export type GovernedFieldTypeV1 = 'TEXT' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'DATE'; +export type GovernedDefinitionStatusV1 = 'DRAFT' | 'PUBLISHED' | 'RETIRED'; +export type FieldSensitivityV1 = 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' | 'RESTRICTED'; +export type DefaultBehaviorV1 = 'MISSING' | 'NULL' | 'STATIC' | 'NONE'; +export type QualityStateV1 = 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; +export type SchemaCompatibilityV1 = + | 'ADDITIVE_COMPATIBLE' + | 'VALIDATION_TIGHTENING' + | 'MIGRATION_REQUIRED' + | 'BREAKING'; + +export interface GovernedDatasetFieldV1 { + readonly fieldId: StableIdentifierV1; + readonly name: string; + readonly type: GovernedFieldTypeV1; + readonly nullable: boolean; + readonly unit?: string; + readonly semanticRole?: string; + readonly aliases: readonly string[]; + readonly localizedLabels: Readonly>; + readonly sensitivity: FieldSensitivityV1; + readonly defaultBehavior: DefaultBehaviorV1; +} + +export interface GovernedDatasetDefinitionV1 { + readonly schemaVersion: typeof DATASET_GOVERNANCE_SCHEMA_VERSION_V1; + readonly datasetId: StableIdentifierV1; + readonly versionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly name: string; + readonly fields: readonly GovernedDatasetFieldV1[]; + readonly status: GovernedDefinitionStatusV1; + readonly createdAt: StrictUtcTimestampV1; + readonly publishedAt?: StrictUtcTimestampV1; + readonly canonicalHash: string; +} + +export interface DatasetVersionManifestV1 { + readonly schemaVersion: typeof DATASET_GOVERNANCE_SCHEMA_VERSION_V1; + readonly datasetId: StableIdentifierV1; + readonly versionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly inputArtifactVersionIds: readonly StableIdentifierV1[]; + readonly schemaVersionId: StableIdentifierV1; + readonly mappingVersionId: StableIdentifierV1; + readonly ruleSetVersionId: StableIdentifierV1; + readonly engineBuild: string; + readonly contentFingerprint: string; + readonly rowCount: number; + readonly qualityState: QualityStateV1; + readonly lineageManifestHash: string; +} + +export type DatasetGovernanceErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_TEXT' + | 'INVALID_FIELD' + | 'DUPLICATE_FIELD' + | 'INVALID_STATE' + | 'INVALID_HASH' + | 'INVALID_COUNT' + | 'INVALID_QUALITY_STATE' + | 'INCOMPATIBLE_SCHEMA'; + +export type DatasetGovernanceResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: DatasetGovernanceErrorCodeV1 }; + +function accepted(value: TValue): DatasetGovernanceResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: DatasetGovernanceErrorCodeV1): DatasetGovernanceResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const result = parseTenantScopeV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function field(input: unknown): GovernedDatasetFieldV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const fieldId = identifier(record['fieldId']); + const name = text(record['name'], 128); + const type = record['type']; + const nullable = record['nullable']; + const unit = record['unit'] === undefined ? undefined : text(record['unit'], 64); + const semanticRole = + record['semanticRole'] === undefined ? undefined : text(record['semanticRole'], 128); + const aliasesInput = record['aliases'] === undefined ? [] : record['aliases']; + const localizedInput = record['localizedLabels'] === undefined ? {} : record['localizedLabels']; + const sensitivity = record['sensitivity'] ?? 'PUBLIC'; + const defaultBehavior = record['defaultBehavior'] ?? 'NONE'; + if (!fieldId || !name || !['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes(type as string)) + return undefined; + if (typeof nullable !== 'boolean') return undefined; + if (record['unit'] !== undefined && !unit) return undefined; + if (record['semanticRole'] !== undefined && !semanticRole) return undefined; + if (!Array.isArray(aliasesInput) || aliasesInput.length > 32) return undefined; + const aliases = aliasesInput.map((alias) => text(alias, 128)); + if (aliases.some((alias): alias is undefined => alias === undefined)) return undefined; + if ( + typeof localizedInput !== 'object' || + localizedInput === null || + Array.isArray(localizedInput) + ) + return undefined; + const localizedLabels: Record = {}; + for (const [locale, label] of Object.entries(localizedInput)) { + const safeLocale = text(locale, 16); + const safeLabel = text(label, 255); + if (!safeLocale || !safeLabel) return undefined; + localizedLabels[safeLocale] = safeLabel; + } + if (!['PUBLIC', 'INTERNAL', 'CONFIDENTIAL', 'RESTRICTED'].includes(sensitivity as string)) + return undefined; + if (!['MISSING', 'NULL', 'STATIC', 'NONE'].includes(defaultBehavior as string)) return undefined; + return Object.freeze({ + fieldId, + name, + type: type as GovernedFieldTypeV1, + nullable, + ...(unit ? { unit } : {}), + ...(semanticRole ? { semanticRole } : {}), + aliases: Object.freeze(aliases as string[]), + localizedLabels: Object.freeze(localizedLabels), + sensitivity: sensitivity as FieldSensitivityV1, + defaultBehavior: defaultBehavior as DefaultBehaviorV1, + }); +} + +export function createGovernedDatasetDefinitionV1(input: { + readonly datasetId: unknown; + readonly versionId: unknown; + readonly tenantScope: unknown; + readonly name: unknown; + readonly fields: unknown; + readonly status?: unknown; + readonly createdAt: unknown; + readonly publishedAt?: unknown; + readonly canonicalHash: unknown; +}): DatasetGovernanceResultV1 { + const datasetId = identifier(input.datasetId); + const versionId = identifier(input.versionId); + const tenantScope = scope(input.tenantScope); + const name = text(input.name, 200); + const createdAt = timestamp(input.createdAt); + const publishedAt = input.publishedAt === undefined ? undefined : timestamp(input.publishedAt); + const canonicalHash = hash(input.canonicalHash); + if (!datasetId || !versionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!name) return rejected('INVALID_TEXT'); + if (!Array.isArray(input.fields) || input.fields.length === 0 || input.fields.length > 256) + return rejected('INVALID_FIELD'); + const fields = input.fields.map(field); + if (fields.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_FIELD'); + const validFields = fields as GovernedDatasetFieldV1[]; + const fieldIds = new Set(validFields.map((candidate) => candidate.fieldId)); + const fieldNames = new Set(validFields.map((candidate) => candidate.name)); + if (fieldIds.size !== validFields.length || fieldNames.size !== validFields.length) + return rejected('DUPLICATE_FIELD'); + if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) + return rejected('INVALID_TIMESTAMP'); + const status = input.status ?? 'DRAFT'; + if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) return rejected('INVALID_STATE'); + if (!canonicalHash) return rejected('INVALID_HASH'); + if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + schemaVersion: DATASET_GOVERNANCE_SCHEMA_VERSION_V1, + datasetId, + versionId, + tenantScope, + name, + fields: Object.freeze(validFields), + status: status as GovernedDefinitionStatusV1, + createdAt, + ...(publishedAt ? { publishedAt } : {}), + canonicalHash, + }), + ); +} + +export function compareGovernedSchemaCompatibilityV1( + previous: GovernedDatasetDefinitionV1, + next: GovernedDatasetDefinitionV1, +): DatasetGovernanceResultV1 { + if (previous.datasetId !== next.datasetId || !tenantScopesEqualV1(previous.tenantScope, next.tenantScope)) + return rejected('INCOMPATIBLE_SCHEMA'); + const nextById = new Map(next.fields.map((candidate) => [candidate.fieldId, candidate])); + let classification: SchemaCompatibilityV1 = 'ADDITIVE_COMPATIBLE'; + for (const prior of previous.fields) { + const candidate = nextById.get(prior.fieldId); + if (!candidate || candidate.name !== prior.name || candidate.type !== prior.type) + return accepted('BREAKING'); + if (prior.nullable && !candidate.nullable) classification = 'VALIDATION_TIGHTENING'; + } + for (const candidate of next.fields) { + if (!previous.fields.some((prior) => prior.fieldId === candidate.fieldId)) { + if (!candidate.nullable) classification = 'MIGRATION_REQUIRED'; + } + } + return accepted(classification); +} + +export function createDatasetVersionManifestV1(input: { + readonly datasetId: unknown; + readonly versionId: unknown; + readonly tenantScope: unknown; + readonly inputArtifactVersionIds: unknown; + readonly schemaVersionId: unknown; + readonly mappingVersionId: unknown; + readonly ruleSetVersionId: unknown; + readonly engineBuild: unknown; + readonly contentFingerprint: unknown; + readonly rowCount: unknown; + readonly qualityState: unknown; + readonly lineageManifestHash: unknown; +}): DatasetGovernanceResultV1 { + const datasetId = identifier(input.datasetId); + const versionId = identifier(input.versionId); + const tenantScope = scope(input.tenantScope); + const schemaVersionId = identifier(input.schemaVersionId); + const mappingVersionId = identifier(input.mappingVersionId); + const ruleSetVersionId = identifier(input.ruleSetVersionId); + const contentFingerprint = hash(input.contentFingerprint); + const lineageManifestHash = hash(input.lineageManifestHash); + const engineBuild = text(input.engineBuild, 128); + if (!datasetId || !versionId || !schemaVersionId || !mappingVersionId || !ruleSetVersionId) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!Array.isArray(input.inputArtifactVersionIds) || input.inputArtifactVersionIds.length > 1024) + return rejected('INVALID_IDENTIFIER'); + const inputArtifactVersionIds = input.inputArtifactVersionIds.map(identifier); + if (inputArtifactVersionIds.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_IDENTIFIER'); + if (!engineBuild || !contentFingerprint || !lineageManifestHash) return rejected('INVALID_TEXT'); + if ( + typeof input.rowCount !== 'number' || + !Number.isSafeInteger(input.rowCount) || + input.rowCount < 0 + ) + return rejected('INVALID_COUNT'); + if (!['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'].includes(input.qualityState as string)) + return rejected('INVALID_QUALITY_STATE'); + return accepted( + Object.freeze({ + schemaVersion: DATASET_GOVERNANCE_SCHEMA_VERSION_V1, + datasetId, + versionId, + tenantScope, + inputArtifactVersionIds: Object.freeze(inputArtifactVersionIds as StableIdentifierV1[]), + schemaVersionId, + mappingVersionId, + ruleSetVersionId, + engineBuild, + contentFingerprint, + rowCount: input.rowCount, + qualityState: input.qualityState as QualityStateV1, + lineageManifestHash, + }), + ); +} diff --git a/packages/domain/src/reference-entity/v1.ts b/packages/domain/src/reference-entity/v1.ts new file mode 100644 index 00000000..bdc3f175 --- /dev/null +++ b/packages/domain/src/reference-entity/v1.ts @@ -0,0 +1,208 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-025..DSM-027: one canonical, versioned workspace reference identity. */ +export const REFERENCE_ENTITY_SCHEMA_VERSION_V1 = 1 as const; + +export type BusinessPartyRoleV1 = 'SUPPLIER' | 'CUSTOMER' | 'CARRIER' | 'OTHER'; +export type BusinessPartyStatusV1 = 'ACTIVE' | 'INACTIVE' | 'MERGED'; +export type BusinessPartyVisibilityV1 = 'WORKSPACE' | 'PROJECT'; + +export interface ExternalIdentifierV1 { + readonly namespace: string; + readonly value: string; +} + +export interface BusinessPartyVersionV1 { + readonly schemaVersion: typeof REFERENCE_ENTITY_SCHEMA_VERSION_V1; + readonly entityId: StableIdentifierV1; + readonly versionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly entityType: 'BUSINESS_PARTY'; + readonly displayName: string; + readonly roles: readonly BusinessPartyRoleV1[]; + readonly aliases: readonly string[]; + readonly externalIdentifiers: readonly ExternalIdentifierV1[]; + readonly status: BusinessPartyStatusV1; + readonly visibility: BusinessPartyVisibilityV1; + readonly canonicalHash: string; + readonly createdAt: StrictUtcTimestampV1; +} + +export interface BusinessPartyResolutionV1 { + readonly schemaVersion: typeof REFERENCE_ENTITY_SCHEMA_VERSION_V1; + readonly resolutionId: StableIdentifierV1; + readonly resolutionType: 'MERGE'; + readonly sourceEntityId: StableIdentifierV1; + readonly targetEntityId: StableIdentifierV1; + readonly actorId: StableIdentifierV1; + readonly reason: string; + readonly evidenceId: StableIdentifierV1; + readonly resolvedAt: StrictUtcTimestampV1; +} + +export type ReferenceEntityErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_TEXT' + | 'INVALID_ROLE' + | 'DUPLICATE_VALUE' + | 'INVALID_STATE' + | 'INVALID_HASH' + | 'SAME_ENTITY' + | 'CROSS_SCOPE'; + +export type ReferenceEntityResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ReferenceEntityErrorCodeV1 }; + +function accepted(value: TValue): ReferenceEntityResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ReferenceEntityErrorCodeV1): ReferenceEntityResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const result = parseTenantScopeV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +export function createBusinessPartyVersionV1(input: { + readonly entityId: unknown; + readonly versionId: unknown; + readonly tenantScope: unknown; + readonly displayName: unknown; + readonly roles: unknown; + readonly aliases?: unknown; + readonly externalIdentifiers?: unknown; + readonly status?: unknown; + readonly visibility?: unknown; + readonly canonicalHash: unknown; + readonly createdAt: unknown; +}): ReferenceEntityResultV1 { + const entityId = identifier(input.entityId); + const versionId = identifier(input.versionId); + const tenantScope = scope(input.tenantScope); + const displayName = text(input.displayName, 255); + const canonicalHash = hash(input.canonicalHash); + const createdAt = timestamp(input.createdAt); + if (!entityId || !versionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope || tenantScope.scopeType === 'organization') return rejected('INVALID_SCOPE'); + if (!displayName) return rejected('INVALID_TEXT'); + if (!canonicalHash) return rejected('INVALID_HASH'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (!Array.isArray(input.roles) || input.roles.length === 0 || input.roles.length > 8) + return rejected('INVALID_ROLE'); + const roles = input.roles.filter((role): role is BusinessPartyRoleV1 => + ['SUPPLIER', 'CUSTOMER', 'CARRIER', 'OTHER'].includes(role as string), + ); + if (roles.length !== input.roles.length || new Set(roles).size !== roles.length) + return rejected('INVALID_ROLE'); + const aliasesInput = input.aliases === undefined ? [] : input.aliases; + if (!Array.isArray(aliasesInput) || aliasesInput.length > 64) return rejected('INVALID_TEXT'); + const aliases = aliasesInput.map((alias) => text(alias, 255)); + if (aliases.some((alias): alias is undefined => alias === undefined)) return rejected('INVALID_TEXT'); + const externalInput = input.externalIdentifiers === undefined ? [] : input.externalIdentifiers; + if (!Array.isArray(externalInput) || externalInput.length > 64) + return rejected('INVALID_TEXT'); + const externalIdentifiers: ExternalIdentifierV1[] = []; + for (const candidate of externalInput) { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) + return rejected('INVALID_TEXT'); + const record = candidate as Record; + const namespace = text(record['namespace'], 64); + const value = text(record['value'], 255); + if (!namespace || !value) return rejected('INVALID_TEXT'); + externalIdentifiers.push(Object.freeze({ namespace, value })); + } + const externalKeys = externalIdentifiers.map((item) => `${item.namespace}:${item.value}`); + if (new Set(externalKeys).size !== externalKeys.length) return rejected('DUPLICATE_VALUE'); + const status = input.status ?? 'ACTIVE'; + const visibility = input.visibility ?? 'WORKSPACE'; + if (!['ACTIVE', 'INACTIVE', 'MERGED'].includes(status as string)) return rejected('INVALID_STATE'); + if (!['WORKSPACE', 'PROJECT'].includes(visibility as string)) return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + schemaVersion: REFERENCE_ENTITY_SCHEMA_VERSION_V1, + entityId, + versionId, + tenantScope, + entityType: 'BUSINESS_PARTY' as const, + displayName, + roles: Object.freeze(roles), + aliases: Object.freeze(aliases as string[]), + externalIdentifiers: Object.freeze(externalIdentifiers), + status: status as BusinessPartyStatusV1, + visibility: visibility as BusinessPartyVisibilityV1, + canonicalHash, + createdAt, + }), + ); +} + +export function mergeBusinessPartyVersionsV1(input: { + readonly source: BusinessPartyVersionV1; + readonly target: BusinessPartyVersionV1; + readonly resolutionId: unknown; + readonly actorId: unknown; + readonly reason: unknown; + readonly evidenceId: unknown; + readonly resolvedAt: unknown; +}): ReferenceEntityResultV1 { + const resolutionId = identifier(input.resolutionId); + const actorId = identifier(input.actorId); + const evidenceId = identifier(input.evidenceId); + const reason = text(input.reason, 512); + const resolvedAt = timestamp(input.resolvedAt); + if (!resolutionId || !actorId || !evidenceId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScopesEqualV1(input.source.tenantScope, input.target.tenantScope)) + return rejected('CROSS_SCOPE'); + if (input.source.entityId === input.target.entityId) return rejected('SAME_ENTITY'); + if (!reason || !resolvedAt) return rejected(reason ? 'INVALID_TIMESTAMP' : 'INVALID_TEXT'); + return accepted( + Object.freeze({ + schemaVersion: REFERENCE_ENTITY_SCHEMA_VERSION_V1, + resolutionId, + resolutionType: 'MERGE' as const, + sourceEntityId: input.source.entityId, + targetEntityId: input.target.entityId, + actorId, + reason, + evidenceId, + resolvedAt, + }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 4695c59b..17a0740b 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -1,7 +1,9 @@ export * from './authorization/v1.js'; export * from './audit/v1.js'; export * from './artifact/v1.js'; +export * from './artifact-intake/v1.js'; export * from './dataset/v1.js'; +export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; @@ -9,6 +11,7 @@ export * from './result-manifest/v1.js'; export * from './dispatch/v1.js'; export * from './recipe/v1.js'; export * from './finding/v1.js'; +export * from './reference-entity/v1.js'; export * from './identity/v1.js'; export * from './entitlements/v1.js'; export * from './mfa/v1.js'; diff --git a/packages/domain/test/artifact-intake-v1.test.mjs b/packages/domain/test/artifact-intake-v1.test.mjs new file mode 100644 index 00000000..0ff97b2f --- /dev/null +++ b/packages/domain/test/artifact-intake-v1.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createInboxItemV1, + finalizeArtifactAdmissionV1, + transitionInboxItemV1, +} from '../dist/artifact-intake/v1.js'; +import { createArtifactVersionV1 } from '../dist/artifact/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; +const baseArtifact = { + artifactId: '00000000-0000-4000-8000-000000000010', + versionId: '00000000-0000-4000-8000-000000000011', + tenantScope: scope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 24, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[IAE-001, IAE-013] inbox creation is idempotency-bound and transitions explicitly', () => { + const created = createInboxItemV1({ + inboxItemId: '00000000-0000-4000-8000-000000000020', + tenantScope: scope, + idempotencyKey: 'capture-1', + artifactVersionId: baseArtifact.versionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.state, 'NEW'); + const routed = transitionInboxItemV1(created.value, 'ROUTED'); + assert.equal(routed.accepted, true); + if (!routed.accepted) return; + assert.deepEqual(transitionInboxItemV1(created.value, 'ARCHIVED'), { + accepted: false, + code: 'INVALID_TRANSITION', + }); +}); + +void test('[IAE-009, IAE-010] admission requires digest, size, media signature, and clean scan', () => { + const artifact = createArtifactVersionV1(baseArtifact); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) return; + assert.deepEqual( + finalizeArtifactAdmissionV1({ + artifact: artifact.value, + actualSha256: 'a'.repeat(64), + actualByteSize: 24, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }), + { accepted: true, value: { status: 'ACTIVE', scanState: 'CLEAN' } }, + ); + assert.deepEqual( + finalizeArtifactAdmissionV1({ + artifact: artifact.value, + actualSha256: 'b'.repeat(64), + actualByteSize: 24, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }), + { accepted: false, code: 'DIGEST_MISMATCH' }, + ); + assert.deepEqual( + finalizeArtifactAdmissionV1({ + artifact: artifact.value, + actualSha256: 'a'.repeat(64), + actualByteSize: 24, + detectedMediaType: 'text/csv', + scanState: 'MALICIOUS', + maxByteSize: 100, + }), + { accepted: true, value: { status: 'QUARANTINED', scanState: 'MALICIOUS' } }, + ); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 68958793..c2e71810 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -6,7 +6,9 @@ const [ tenantScope, authorization, artifact, + artifactIntake, dataset, + datasetGovernance, dataMode, jobs, approval, @@ -15,13 +17,16 @@ const [ dispatch, recipe, finding, + referenceEntity, ] = await Promise.all([ import('@databreeze/domain/v1'), import('@databreeze/domain/permissions/v1'), import('@databreeze/domain/tenant-scope/v1'), import('@databreeze/domain/authorization/v1'), import('@databreeze/domain/artifact/v1'), + import('@databreeze/domain/artifact-intake/v1'), import('@databreeze/domain/dataset/v1'), + import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -30,6 +35,7 @@ const [ import('@databreeze/domain/dispatch/v1'), import('@databreeze/domain/recipe/v1'), import('@databreeze/domain/finding/v1'), + import('@databreeze/domain/reference-entity/v1'), ]); assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); @@ -37,8 +43,10 @@ assert.equal(aggregate.AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(permissions.PERMISSION_SCHEMA_VERSION_V1, 1); assert.equal(typeof tenantScope.parseTenantScopeV1, 'function'); assert.equal(typeof authorization.createScopedAuthorizationEvaluatorV1, 'function'); -assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); -assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); + assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); + assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); + assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); + assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); @@ -46,5 +54,6 @@ assert.equal(executionAttempt.EXECUTION_ATTEMPT_SCHEMA_VERSION_V1, 1); assert.equal(resultManifest.RESULT_MANIFEST_SCHEMA_VERSION_V1, 1); assert.equal(dispatch.DISPATCH_SCHEMA_VERSION_V1, 1); assert.equal(recipe.RECIPE_SCHEMA_VERSION_V1, 1); -assert.equal(finding.FINDING_SCHEMA_VERSION_V1, 1); + assert.equal(finding.FINDING_SCHEMA_VERSION_V1, 1); + assert.equal(referenceEntity.REFERENCE_ENTITY_SCHEMA_VERSION_V1, 1); await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/dataset-governance-v1.test.mjs b/packages/domain/test/dataset-governance-v1.test.mjs new file mode 100644 index 00000000..20fac898 --- /dev/null +++ b/packages/domain/test/dataset-governance-v1.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + compareGovernedSchemaCompatibilityV1, + createDatasetVersionManifestV1, + createGovernedDatasetDefinitionV1, +} from '../dist/dataset-governance/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; +const ids = { + datasetId: '00000000-0000-4000-8000-000000000010', + versionId: '00000000-0000-4000-8000-000000000011', + amountFieldId: '00000000-0000-4000-8000-000000000012', +}; + +function definition(fields = [{ fieldId: ids.amountFieldId, name: 'amount', type: 'DECIMAL', nullable: true }]) { + return createGovernedDatasetDefinitionV1({ + datasetId: ids.datasetId, + versionId: ids.versionId, + tenantScope: scope, + name: 'Orders', + fields, + status: 'DRAFT', + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'c'.repeat(64), + }); +} + +void test('[DSM-001, DSM-004, DSM-006] governed fields keep stable IDs and immutable metadata', () => { + const result = definition(); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.fields[0]?.fieldId, ids.amountFieldId); + assert.equal(Object.isFrozen(result.value.fields[0]), true); + assert.deepEqual( + createGovernedDatasetDefinitionV1({ + ...result.value, + fields: [{ ...result.value.fields[0], fieldId: 'not-a-uuid' }], + }), + { accepted: false, code: 'INVALID_FIELD' }, + ); +}); + +void test('[DSM-005] compatibility distinguishes additive, tightening, migration, and breaking changes', () => { + const previous = definition(); + assert.equal(previous.accepted, true); + if (!previous.accepted) return; + const additive = definition([ + ...previous.value.fields, + { fieldId: '00000000-0000-4000-8000-000000000013', name: 'note', type: 'TEXT', nullable: true }, + ]); + assert.equal(additive.accepted, true); + if (!additive.accepted) return; + assert.deepEqual(compareGovernedSchemaCompatibilityV1(previous.value, additive.value), { + accepted: true, + value: 'ADDITIVE_COMPATIBLE', + }); + const tightening = definition([{ ...previous.value.fields[0], nullable: false }]); + assert.equal(tightening.accepted, true); + if (!tightening.accepted) return; + assert.deepEqual(compareGovernedSchemaCompatibilityV1(previous.value, tightening.value), { + accepted: true, + value: 'VALIDATION_TIGHTENING', + }); + const breaking = definition([{ ...previous.value.fields[0], type: 'TEXT' }]); + assert.equal(breaking.accepted, true); + if (breaking.accepted) + assert.deepEqual(compareGovernedSchemaCompatibilityV1(previous.value, breaking.value), { + accepted: true, + value: 'BREAKING', + }); +}); + +void test('[DSM-002, DSM-012, DSM-014] dataset versions pin every reproducibility input', () => { + const result = createDatasetVersionManifestV1({ + datasetId: ids.datasetId, + versionId: ids.versionId, + tenantScope: scope, + inputArtifactVersionIds: ['00000000-0000-4000-8000-000000000020'], + schemaVersionId: ids.versionId, + mappingVersionId: '00000000-0000-4000-8000-000000000021', + ruleSetVersionId: '00000000-0000-4000-8000-000000000022', + engineBuild: 'engine-2026.08.01', + contentFingerprint: 'd'.repeat(64), + rowCount: 42, + qualityState: 'PASS_WITH_WARNINGS', + lineageManifestHash: 'e'.repeat(64), + }); + assert.equal(result.accepted, true); + if (result.accepted) assert.equal(Object.isFrozen(result.value), true); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 9111ee89..a878426f 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -19,15 +19,18 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './mfa/v1', './device-authorization/v1', './data-mode/v1', - './artifact/v1', - './dataset/v1', + './artifact/v1', + './artifact-intake/v1', + './dataset/v1', + './dataset-governance/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', './result-manifest/v1', './dispatch/v1', './recipe/v1', - './finding/v1', + './finding/v1', + './reference-entity/v1', ]); for (const entry of Object.values(manifest.exports)) { diff --git a/packages/domain/test/reference-entity-v1.test.mjs b/packages/domain/test/reference-entity-v1.test.mjs new file mode 100644 index 00000000..d558f7b8 --- /dev/null +++ b/packages/domain/test/reference-entity-v1.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createBusinessPartyVersionV1, mergeBusinessPartyVersionsV1 } from '../dist/reference-entity/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; +const base = { + entityId: '00000000-0000-4000-8000-000000000010', + versionId: '00000000-0000-4000-8000-000000000011', + tenantScope: scope, + displayName: 'Công ty Ánh Dương', + roles: ['SUPPLIER'], + aliases: ['Anh Duong Co.'], + externalIdentifiers: [{ namespace: 'tax.vn', value: '0101234567' }], + status: 'ACTIVE', + visibility: 'WORKSPACE', + canonicalHash: 'f'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[DSM-025, DSM-026] business-party versions are workspace-scoped and canonical', () => { + const result = createBusinessPartyVersionV1(base); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.displayName, 'Công ty Ánh Dương'); + assert.equal(Object.isFrozen(result.value.externalIdentifiers[0]), true); + assert.deepEqual( + createBusinessPartyVersionV1({ ...base, roles: [] }), + { accepted: false, code: 'INVALID_ROLE' }, + ); +}); + +void test('[DSM-027] merge creates an explicit redirect without retargeting history', () => { + const source = createBusinessPartyVersionV1(base); + const target = createBusinessPartyVersionV1({ + ...base, + entityId: '00000000-0000-4000-8000-000000000012', + versionId: '00000000-0000-4000-8000-000000000013', + displayName: 'Ánh Dương Trading', + canonicalHash: '1'.repeat(64), + }); + assert.equal(source.accepted, true); + assert.equal(target.accepted, true); + if (!source.accepted || !target.accepted) return; + const merged = mergeBusinessPartyVersionsV1({ + source: source.value, + target: target.value, + resolutionId: '00000000-0000-4000-8000-000000000014', + actorId: '00000000-0000-4000-8000-000000000015', + reason: 'Duplicate tax identifier review', + evidenceId: '00000000-0000-4000-8000-000000000016', + resolvedAt: '2026-01-02T00:00:00.000Z', + }); + assert.equal(merged.accepted, true); + if (merged.accepted) { + assert.equal(merged.value.sourceEntityId, source.value.entityId); + assert.equal(merged.value.targetEntityId, target.value.entityId); + } +}); From 2a128ef5d697f74c6d0e2859b1db74181b56c0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:32:44 +0700 Subject: [PATCH 02/44] feat(iae): add idempotent intake admission service --- ...mory-artifact-intake-repository.adapter.ts | 103 ++++++++++++++++++ .../artifact-intake-repository.port.ts | 21 ++++ .../application/artifact-intake.service.ts | 68 ++++++++++++ .../iae/artifact-intake.service.test.ts | 92 ++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts create mode 100644 services/api/src/features/iae/application/artifact-intake-repository.port.ts create mode 100644 services/api/src/features/iae/application/artifact-intake.service.ts create mode 100644 services/api/test/features/iae/artifact-intake.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts new file mode 100644 index 00000000..e1718c74 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts @@ -0,0 +1,103 @@ +import { + tenantScopeContainsV1, + type InboxItemV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactIntakeRepositoryPortV1, + ArtifactIntakeTransactionPortV1, +} from '../application/artifact-intake-repository.port.js'; + +function clone(item: InboxItemV1): InboxItemV1 { + return Object.freeze({ + ...item, + tenantScope: Object.freeze({ ...item.tenantScope }), + }); +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function canMutate(context: IamTenantContextV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context.tenantScope, candidate); +} + +/** IAE repository adapter for deterministic service and transaction tests. */ +export class InMemoryArtifactIntakeRepositoryAdapter implements ArtifactIntakeRepositoryPortV1 { + private items = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, item: InboxItemV1): Promise { + await Promise.resolve(); + if (!canMutate(context, item.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.items.get(item.inboxItemId); + if (existing && JSON.stringify(existing) !== JSON.stringify(item)) { + if (context.expectedRevision !== existing.revision) + throw new Error('IAE_REVISION_CONFLICT'); + if ( + existing.artifactVersionId !== item.artifactVersionId || + existing.idempotencyKey !== item.idempotencyKey || + JSON.stringify(existing.tenantScope) !== JSON.stringify(item.tenantScope) || + item.revision !== existing.revision + 1 + ) + throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); + } + const sameKey = [...this.items.values()].find( + (candidate) => + candidate.idempotencyKey === item.idempotencyKey && + JSON.stringify(candidate.tenantScope) === JSON.stringify(item.tenantScope), + ); + if (sameKey && sameKey.inboxItemId !== item.inboxItemId) + throw new Error('IAE_IDEMPOTENCY_CONFLICT'); + this.items.set(item.inboxItemId, clone(item)); + } + + public async findByIdempotency( + context: IamTenantContextV1, + idempotencyKey: string, + ): Promise { + await Promise.resolve(); + const item = [...this.items.values()].find( + (candidate) => + candidate.idempotencyKey === idempotencyKey && visible(context.tenantScope, candidate.tenantScope), + ); + return item ? clone(item) : undefined; + } + + public async find( + context: IamTenantContextV1, + inboxItemId: InboxItemV1['inboxItemId'], + ): Promise { + await Promise.resolve(); + const item = this.items.get(inboxItemId); + return item && visible(context.tenantScope, item.tenantScope) ? clone(item) : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactIntakeTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.items); + try { + return await work({ + save: this.save.bind(this), + findByIdempotency: this.findByIdempotency.bind(this), + find: this.find.bind(this), + }); + } catch (error) { + this.items = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/application/artifact-intake-repository.port.ts b/services/api/src/features/iae/application/artifact-intake-repository.port.ts new file mode 100644 index 00000000..88455bb4 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-intake-repository.port.ts @@ -0,0 +1,21 @@ +import type { InboxItemV1 } from '@databreeze/domain/artifact-intake/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_INTAKE_REPOSITORY_PORT = Symbol('ARTIFACT_INTAKE_REPOSITORY_PORT'); + +export interface ArtifactIntakeTransactionPortV1 { + save(context: IamTenantContextV1, item: InboxItemV1): Promise; + findByIdempotency( + context: IamTenantContextV1, + idempotencyKey: string, + ): Promise; + find(context: IamTenantContextV1, inboxItemId: InboxItemV1['inboxItemId']): Promise; +} + +export interface ArtifactIntakeRepositoryPortV1 extends ArtifactIntakeTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactIntakeTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/artifact-intake.service.ts b/services/api/src/features/iae/application/artifact-intake.service.ts new file mode 100644 index 00000000..628fe552 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-intake.service.ts @@ -0,0 +1,68 @@ +import { + createInboxItemV1, + finalizeArtifactAdmissionV1, + transitionInboxItemV1, + type ArtifactIntakeResultV1, + type ArtifactScanStateV1, + type InboxItemV1, +} from '@databreeze/domain/artifact-intake/v1'; +import type { ArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactIntakeRepositoryPortV1 } from './artifact-intake-repository.port.js'; + +export type ArtifactIntakeServiceErrorV1 = + | 'IDEMPOTENCY_CONFLICT' + | 'INBOX_NOT_FOUND' + | 'INVALID_TRANSITION'; + +export type ArtifactIntakeServiceResultV1 = + | ArtifactIntakeResultV1 + | { readonly accepted: false; readonly code: ArtifactIntakeServiceErrorV1 }; + +/** Coordinates IAE inbox identity, deterministic admission, and state transitions. */ +export class ArtifactIntakeService { + public constructor(private readonly repository: ArtifactIntakeRepositoryPortV1) {} + + public async create( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createInboxItemV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.findByIdempotency(context, created.value.idempotencyKey); + if (existing) { + if ( + existing.artifactVersionId !== created.value.artifactVersionId || + JSON.stringify(existing.tenantScope) !== JSON.stringify(created.value.tenantScope) + ) + return Object.freeze({ accepted: false, code: 'IDEMPOTENCY_CONFLICT' as const }); + return Object.freeze({ accepted: true, value: existing }); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async admit( + context: IamTenantContextV1, + inboxItemId: InboxItemV1['inboxItemId'], + artifact: ArtifactVersionV1, + input: Omit[0], 'artifact'>, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const item = await transaction.find(context, inboxItemId); + if (!item) return Object.freeze({ accepted: false, code: 'INBOX_NOT_FOUND' as const }); + const admission = finalizeArtifactAdmissionV1({ artifact, ...input }); + if (!admission.accepted) return admission; + const next = transitionInboxItemV1(item, admission.value.status === 'ACTIVE' ? 'ROUTED' : 'QUARANTINED'); + if (!next.accepted) return Object.freeze({ accepted: false, code: 'INVALID_TRANSITION' as const }); + await transaction.save(context, next.value); + return Object.freeze({ + accepted: true, + value: Object.freeze({ item: next.value, ...admission.value }), + }); + }); + } +} diff --git a/services/api/test/features/iae/artifact-intake.service.test.ts b/services/api/test/features/iae/artifact-intake.service.test.ts new file mode 100644 index 00000000..6828d068 --- /dev/null +++ b/services/api/test/features/iae/artifact-intake.service.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { createArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; + +import { InMemoryArtifactIntakeRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.js'; +import { ArtifactIntakeService } from '../../../src/features/iae/application/artifact-intake.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000003'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; + +function context(workspace: string, idempotencyKey: string, expectedRevision?: number) { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + ...(expectedRevision === undefined ? {} : { expectedRevision }), + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +const inbox = { + inboxItemId: '00000000-0000-4000-8000-000000000020', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey: 'intake-1', + artifactVersionId: '00000000-0000-4000-8000-000000000021', + createdAt: '2026-01-01T00:00:00.000Z', +}; +const artifactResult = createArtifactVersionV1({ + artifactId: '00000000-0000-4000-8000-000000000022', + versionId: inbox.artifactVersionId, + tenantScope: inbox.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 24, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-01-01T00:00:00.000Z', + status: 'QUARANTINED', +}); +if (!artifactResult.accepted) throw new Error('invalid artifact fixture'); +const artifact = artifactResult.value; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid identifier'); + return result.value; +} + +void test('[IAE-001] create returns the same inbox item for a repeated key', async () => { + const service = new ArtifactIntakeService(new InMemoryArtifactIntakeRepositoryAdapter()); + const first = await service.create(context(workspaceId, 'create-1'), inbox); + const second = await service.create(context(workspaceId, 'create-2'), inbox); + assert.deepEqual(second, first); + assert.equal((await service.create(context(workspaceId, 'create-3'), { ...inbox, artifactVersionId: '00000000-0000-4000-8000-000000000023' })).accepted, false); +}); + +void test('[IAE-009, IAE-010, IAM-009] admission moves clean content to routed and quarantines malicious content', async () => { + const service = new ArtifactIntakeService(new InMemoryArtifactIntakeRepositoryAdapter()); + const created = await service.create(context(workspaceId, 'admit-1'), inbox); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const admitted = await service.admit(context(workspaceId, 'admit-2', created.value.revision), created.value.inboxItemId, artifact, { + actualSha256: artifact.contentSha256, + actualByteSize: artifact.byteSize, + detectedMediaType: artifact.mediaType, + scanState: 'CLEAN', + maxByteSize: 100, + }); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + assert.equal(admitted.value.item.state, 'ROUTED'); + const sibling = await service.admit(context(siblingWorkspaceId, 'admit-3'), created.value.inboxItemId, artifact, { + actualSha256: artifact.contentSha256, + actualByteSize: artifact.byteSize, + detectedMediaType: artifact.mediaType, + scanState: 'CLEAN', + maxByteSize: 100, + }); + assert.deepEqual(sibling, { accepted: false, code: 'INBOX_NOT_FOUND' }); +}); From bddb2a8bd54f9b43492d65bb67faf4413628ddb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:35:07 +0700 Subject: [PATCH 03/44] feat(dsm): add governed dataset version service --- packages/domain/src/dataset-governance/v1.ts | 22 ++++ .../test/dataset-governance-v1.test.mjs | 17 +++ ...ory-governed-dataset-repository.adapter.ts | 100 ++++++++++++++++++ .../governed-dataset-repository.port.ts | 25 +++++ .../application/governed-dataset.service.ts | 74 +++++++++++++ .../dsm/governed-dataset.service.test.ts | 72 +++++++++++++ 6 files changed, 310 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/governed-dataset-repository.port.ts create mode 100644 services/api/src/features/dsm/application/governed-dataset.service.ts create mode 100644 services/api/test/features/dsm/governed-dataset.service.test.ts diff --git a/packages/domain/src/dataset-governance/v1.ts b/packages/domain/src/dataset-governance/v1.ts index ec87c0fb..75917ad2 100644 --- a/packages/domain/src/dataset-governance/v1.ts +++ b/packages/domain/src/dataset-governance/v1.ts @@ -245,6 +245,28 @@ export function compareGovernedSchemaCompatibilityV1( return accepted(classification); } +export function publishGovernedDatasetDefinitionV1( + definition: GovernedDatasetDefinitionV1, + nextVersionIdInput: unknown, + publishedAtInput: unknown, +): DatasetGovernanceResultV1 { + const nextVersionId = identifier(nextVersionIdInput); + const publishedAt = timestamp(publishedAtInput); + if (!nextVersionId) return rejected('INVALID_IDENTIFIER'); + if (!publishedAt) return rejected('INVALID_TIMESTAMP'); + if (definition.status !== 'DRAFT') return rejected('INVALID_STATE'); + if (Date.parse(publishedAt) < Date.parse(definition.createdAt)) + return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + ...definition, + versionId: nextVersionId, + status: 'PUBLISHED' as const, + publishedAt, + }), + ); +} + export function createDatasetVersionManifestV1(input: { readonly datasetId: unknown; readonly versionId: unknown; diff --git a/packages/domain/test/dataset-governance-v1.test.mjs b/packages/domain/test/dataset-governance-v1.test.mjs index 20fac898..2094aba8 100644 --- a/packages/domain/test/dataset-governance-v1.test.mjs +++ b/packages/domain/test/dataset-governance-v1.test.mjs @@ -5,6 +5,7 @@ import { compareGovernedSchemaCompatibilityV1, createDatasetVersionManifestV1, createGovernedDatasetDefinitionV1, + publishGovernedDatasetDefinitionV1, } from '../dist/dataset-governance/v1.js'; const scope = { @@ -94,3 +95,19 @@ void test('[DSM-002, DSM-012, DSM-014] dataset versions pin every reproducibilit assert.equal(result.accepted, true); if (result.accepted) assert.equal(Object.isFrozen(result.value), true); }); + +void test('[DSM-005, DSM-006] publication creates a new immutable version', () => { + const draft = definition(); + assert.equal(draft.accepted, true); + if (!draft.accepted) return; + const published = publishGovernedDatasetDefinitionV1( + draft.value, + '00000000-0000-4000-8000-000000000099', + '2026-01-01T00:01:00.000Z', + ); + assert.equal(published.accepted, true); + if (!published.accepted) return; + assert.equal(published.value.status, 'PUBLISHED'); + assert.notEqual(published.value.versionId, draft.value.versionId); + assert.equal(draft.value.status, 'DRAFT'); +}); diff --git a/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts new file mode 100644 index 00000000..6814bab9 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts @@ -0,0 +1,100 @@ +import { + tenantScopeContainsV1, + type GovernedDatasetDefinitionV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + GovernedDatasetRepositoryPortV1, + GovernedDatasetTransactionPortV1, +} from '../application/governed-dataset-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(definition: GovernedDatasetDefinitionV1): GovernedDatasetDefinitionV1 { + return Object.freeze({ + ...definition, + tenantScope: Object.freeze({ ...definition.tenantScope }), + fields: Object.freeze( + definition.fields.map((field) => + Object.freeze({ + ...field, + aliases: Object.freeze([...field.aliases]), + localizedLabels: Object.freeze({ ...field.localizedLabels }), + }), + ), + ), + }); +} + +export class InMemoryGovernedDatasetRepositoryAdapter implements GovernedDatasetRepositoryPortV1 { + private definitions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save( + context: IamTenantContextV1, + definition: GovernedDatasetDefinitionV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.definitions.get(definition.versionId); + if (existing && JSON.stringify(existing) !== JSON.stringify(definition)) + throw new Error('DSM_IMMUTABLE_DEFINITION'); + this.definitions.set(definition.versionId, clone(definition)); + } + + public async find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const definition = this.definitions.get(versionId); + return definition && visible(context.tenantScope, definition.tenantScope) + ? clone(definition) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + return [...this.definitions.values()] + .filter( + (definition) => + definition.datasetId === datasetId && visible(context.tenantScope, definition.tenantScope), + ) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: GovernedDatasetTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.definitions); + try { + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); + } catch (error) { + this.definitions = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/governed-dataset-repository.port.ts b/services/api/src/features/dsm/application/governed-dataset-repository.port.ts new file mode 100644 index 00000000..2f7e9025 --- /dev/null +++ b/services/api/src/features/dsm/application/governed-dataset-repository.port.ts @@ -0,0 +1,25 @@ +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { GovernedDatasetDefinitionV1 } from '@databreeze/domain/dataset-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const GOVERNED_DATASET_REPOSITORY_PORT = Symbol('GOVERNED_DATASET_REPOSITORY_PORT'); + +export interface GovernedDatasetTransactionPortV1 { + save(context: IamTenantContextV1, definition: GovernedDatasetDefinitionV1): Promise; + find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise; + list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise; +} + +export interface GovernedDatasetRepositoryPortV1 extends GovernedDatasetTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: GovernedDatasetTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/governed-dataset.service.ts b/services/api/src/features/dsm/application/governed-dataset.service.ts new file mode 100644 index 00000000..ea949f77 --- /dev/null +++ b/services/api/src/features/dsm/application/governed-dataset.service.ts @@ -0,0 +1,74 @@ +import { + compareGovernedSchemaCompatibilityV1, + createGovernedDatasetDefinitionV1, + publishGovernedDatasetDefinitionV1, + type DatasetGovernanceResultV1, + type GovernedDatasetDefinitionV1, + type SchemaCompatibilityV1, +} from '@databreeze/domain/dataset-governance/v1'; +import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { GovernedDatasetRepositoryPortV1 } from './governed-dataset-repository.port.js'; + +export type GovernedDatasetServiceErrorV1 = 'VERSION_NOT_FOUND'; +export type GovernedDatasetServiceResultV1 = + | DatasetGovernanceResultV1 + | { readonly accepted: false; readonly code: GovernedDatasetServiceErrorV1 }; + +export class GovernedDatasetService { + public constructor(private readonly repository: GovernedDatasetRepositoryPortV1) {} + + public async create( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createGovernedDatasetDefinitionV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.versionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) return created; + throw new Error('DSM_IMMUTABLE_DEFINITION'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async publish( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + nextVersionIdInput: unknown, + publishedAt: unknown, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, versionId); + if (!current) return Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); + const published = publishGovernedDatasetDefinitionV1(current, nextVersionIdInput, publishedAt); + if (!published.accepted) return published; + await transaction.save(context, published.value); + return published; + }); + } + + public async compare( + context: IamTenantContextV1, + previousVersionId: StableIdentifierV1, + nextVersionId: StableIdentifierV1, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const previous = await transaction.find(context, previousVersionId); + const next = await transaction.find(context, nextVersionId); + if (!previous || !next) return Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); + return compareGovernedSchemaCompatibilityV1(previous, next); + }); + } + + public async list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise { + return this.repository.withTransaction(context, (transaction) => transaction.list(context, datasetId)); + } +} diff --git a/services/api/test/features/dsm/governed-dataset.service.test.ts b/services/api/test/features/dsm/governed-dataset.service.test.ts new file mode 100644 index 00000000..6a1a5d85 --- /dev/null +++ b/services/api/test/features/dsm/governed-dataset.service.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryGovernedDatasetRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.js'; +import { GovernedDatasetService } from '../../../src/features/dsm/application/governed-dataset.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000003'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; + +function context(workspace: string, idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid identifier'); + return result.value; +} + +const input = { + datasetId: '00000000-0000-4000-8000-000000000020', + versionId: '00000000-0000-4000-8000-000000000021', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + name: 'Orders', + fields: [{ fieldId: '00000000-0000-4000-8000-000000000022', name: 'amount', type: 'DECIMAL', nullable: true }], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), +}; + +void test('[DSM-001, DSM-004, DSM-005, DSM-006] service creates, publishes, compares, and lists governed versions', async () => { + const service = new GovernedDatasetService(new InMemoryGovernedDatasetRepositoryAdapter()); + const created = await service.create(context(workspaceId, 'governed-1'), input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const published = await service.publish( + context(workspaceId, 'governed-2'), + stable(input.versionId), + '00000000-0000-4000-8000-000000000023', + '2026-01-01T00:01:00.000Z', + ); + assert.equal(published.accepted, true); + if (!published.accepted) return; + const comparison = await service.compare( + context(workspaceId, 'governed-3'), + stable(input.versionId), + stable('00000000-0000-4000-8000-000000000023'), + ); + assert.deepEqual(comparison, { accepted: true, value: 'ADDITIVE_COMPATIBLE' }); + assert.equal((await service.list(context(workspaceId, 'governed-4'), stable(input.datasetId))).length, 2); +}); + +void test('[IAM-009, DSM-018] governed definitions do not cross sibling workspaces', async () => { + const service = new GovernedDatasetService(new InMemoryGovernedDatasetRepositoryAdapter()); + await service.create(context(workspaceId, 'governed-scope-1'), input); + assert.equal((await service.list(context(siblingWorkspaceId, 'governed-scope-2'), stable(input.datasetId))).length, 0); +}); From 087b6284bff6f8db62477e1a176a31977fdeca4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:40:19 +0700 Subject: [PATCH 04/44] feat(storage): persist IAE and DSM governance records --- .../migration.sql | 92 +++++++++++++++++++ services/api/prisma/schema/dsm.prisma | 66 +++++++++++++ services/api/prisma/schema/iae.prisma | 36 ++++++++ services/api/test/prisma-foundation.test.mjs | 24 +++++ 4 files changed, 218 insertions(+) create mode 100644 services/api/prisma/migrations/20260802100000_iae_dsm_governance/migration.sql diff --git a/services/api/prisma/migrations/20260802100000_iae_dsm_governance/migration.sql b/services/api/prisma/migrations/20260802100000_iae_dsm_governance/migration.sql new file mode 100644 index 00000000..9bfedbcc --- /dev/null +++ b/services/api/prisma/migrations/20260802100000_iae_dsm_governance/migration.sql @@ -0,0 +1,92 @@ +-- IAE/DSM governance expansion: intake identity, lineage, dataset versions, and reference entities. +ALTER TABLE "iae"."artifact_versions" + ADD COLUMN "scan_state" VARCHAR(16) NOT NULL DEFAULT 'PENDING'; + +CREATE TABLE "iae"."inbox_items" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "idempotency_key" VARCHAR(200) NOT NULL, + "artifact_version_id" UUID NOT NULL, + "state" VARCHAR(24) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revision" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "inbox_items_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "inbox_items_scope_idempotency_key" + ON "iae"."inbox_items"("organization_id", "workspace_id", "project_id", "idempotency_key"); +CREATE INDEX "inbox_items_artifact_version_idx" ON "iae"."inbox_items"("artifact_version_id"); +CREATE INDEX "inbox_items_scope_state_idx" ON "iae"."inbox_items"("organization_id", "workspace_id", "project_id", "state"); + +CREATE TABLE "iae"."artifact_lineage" ( + "id" UUID NOT NULL, + "derived_artifact_version_id" UUID NOT NULL, + "source_version_ids" JSONB NOT NULL, + "processor_version" VARCHAR(128) NOT NULL, + "recipe_version" VARCHAR(128), + "coordinate_lineage" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "artifact_lineage_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "artifact_lineage_derived_version_idx" ON "iae"."artifact_lineage"("derived_artifact_version_id"); + +ALTER TABLE "dsm"."dataset_definitions" + ADD COLUMN "canonical_hash" CHAR(64) NOT NULL DEFAULT repeat('0', 64); + +CREATE TABLE "dsm"."dataset_versions" ( + "id" UUID NOT NULL, + "dataset_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "input_artifact_version_ids" JSONB NOT NULL, + "schema_version_id" UUID NOT NULL, + "mapping_version_id" UUID NOT NULL, + "rule_set_version_id" UUID NOT NULL, + "engine_build" VARCHAR(128) NOT NULL, + "content_fingerprint" CHAR(64) NOT NULL, + "row_count" BIGINT NOT NULL, + "quality_state" VARCHAR(24) NOT NULL, + "lineage_manifest_hash" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "dataset_versions_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "dataset_versions_dataset_version_key" ON "dsm"."dataset_versions"("dataset_id", "id"); +CREATE INDEX "dataset_versions_scope_idx" ON "dsm"."dataset_versions"("organization_id", "workspace_id", "project_id", "dataset_id"); + +CREATE TABLE "dsm"."reference_entity_versions" ( + "id" UUID NOT NULL, + "entity_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "entity_type" VARCHAR(32) NOT NULL, + "display_name" VARCHAR(255) NOT NULL, + "roles" JSONB NOT NULL, + "aliases" JSONB NOT NULL, + "external_identifiers" JSONB NOT NULL, + "status" VARCHAR(16) NOT NULL, + "visibility" VARCHAR(16) NOT NULL, + "canonical_hash" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "reference_entity_versions_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "reference_entity_versions_entity_version_key" ON "dsm"."reference_entity_versions"("entity_id", "id"); +CREATE INDEX "reference_entities_scope_idx" ON "dsm"."reference_entity_versions"("organization_id", "workspace_id", "project_id", "entity_id"); + +CREATE TABLE "dsm"."reference_entity_resolutions" ( + "id" UUID NOT NULL, + "source_entity_id" UUID NOT NULL, + "target_entity_id" UUID NOT NULL, + "actor_id" UUID NOT NULL, + "reason" VARCHAR(512) NOT NULL, + "evidence_id" UUID NOT NULL, + "resolved_at" TIMESTAMPTZ(6) NOT NULL, + CONSTRAINT "reference_entity_resolutions_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "reference_entity_resolutions_source_idx" ON "dsm"."reference_entity_resolutions"("source_entity_id"); +CREATE INDEX "reference_entity_resolutions_target_idx" ON "dsm"."reference_entity_resolutions"("target_entity_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index 4677c7b0..ebc1d5d4 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -15,6 +15,7 @@ model DatasetDefinitionRecord { createdAt DateTime @map("created_at") @db.Timestamptz(6) publishedAt DateTime? @map("published_at") @db.Timestamptz(6) revision Int @default(1) + canonicalHash String @map("canonical_hash") @db.Char(64) @@unique([datasetId, id], map: "dataset_definitions_dataset_version_key") @@index([organizationId, workspaceId, projectId, datasetId], map: "dataset_definitions_scope_idx") @@ -22,3 +23,68 @@ model DatasetDefinitionRecord { @@map("dataset_definitions") @@schema("dsm") } + +/// DSM-002, DSM-012, DSM-014: immutable result metadata pinned to exact inputs and definitions. +model DatasetVersionRecord { + id String @id @db.Uuid + datasetId String @map("dataset_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + inputArtifactVersionIds Json @map("input_artifact_version_ids") + schemaVersionId String @map("schema_version_id") @db.Uuid + mappingVersionId String @map("mapping_version_id") @db.Uuid + ruleSetVersionId String @map("rule_set_version_id") @db.Uuid + engineBuild String @map("engine_build") @db.VarChar(128) + contentFingerprint String @map("content_fingerprint") @db.Char(64) + rowCount BigInt @map("row_count") + qualityState String @map("quality_state") @db.VarChar(24) + lineageManifestHash String @map("lineage_manifest_hash") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@unique([datasetId, id], map: "dataset_versions_dataset_version_key") + @@index([organizationId, workspaceId, projectId, datasetId], map: "dataset_versions_scope_idx") + @@map("dataset_versions") + @@schema("dsm") +} + +/// DSM-025: canonical workspace reference identities are versioned and immutable. +model ReferenceEntityVersionRecord { + id String @id @db.Uuid + entityId String @map("entity_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + entityType String @map("entity_type") @db.VarChar(32) + displayName String @map("display_name") @db.VarChar(255) + roles Json + aliases Json + externalIdentifiers Json @map("external_identifiers") + status String @db.VarChar(16) + visibility String @db.VarChar(16) + canonicalHash String @map("canonical_hash") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@unique([entityId, id], map: "reference_entity_versions_entity_version_key") + @@index([organizationId, workspaceId, projectId, entityId], map: "reference_entities_scope_idx") + @@map("reference_entity_versions") + @@schema("dsm") +} + +/// DSM-027: merge/split decisions never retarget historical bindings. +model ReferenceEntityResolutionRecord { + id String @id @db.Uuid + sourceEntityId String @map("source_entity_id") @db.Uuid + targetEntityId String @map("target_entity_id") @db.Uuid + actorId String @map("actor_id") @db.Uuid + reason String @db.VarChar(512) + evidenceId String @map("evidence_id") @db.Uuid + resolvedAt DateTime @map("resolved_at") @db.Timestamptz(6) + + @@index([sourceEntityId], map: "reference_entity_resolutions_source_idx") + @@index([targetEntityId], map: "reference_entity_resolutions_target_idx") + @@map("reference_entity_resolutions") + @@schema("dsm") +} diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index e7e89a4c..05c2a09d 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -15,6 +15,7 @@ model ArtifactVersion { displayName String @map("display_name") @db.VarChar(255) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) status String @default("ACTIVE") @db.VarChar(24) + scanState String @default("PENDING") @map("scan_state") @db.VarChar(16) @@index([artifactId], map: "artifact_versions_artifact_id_idx") @@index([organizationId, workspaceId, projectId], map: "artifact_versions_scope_idx") @@ -23,6 +24,41 @@ model ArtifactVersion { @@schema("iae") } +/// IAE-001, IAE-013: idempotent intake state before routing or processing. +model InboxItem { + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + idempotencyKey String @map("idempotency_key") @db.VarChar(200) + artifactVersionId String @map("artifact_version_id") @db.Uuid + state String @db.VarChar(24) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + revision Int @default(1) + + @@unique([organizationId, workspaceId, projectId, idempotencyKey], map: "inbox_items_scope_idempotency_key") + @@index([artifactVersionId], map: "inbox_items_artifact_version_idx") + @@index([organizationId, workspaceId, projectId, state], map: "inbox_items_scope_state_idx") + @@map("inbox_items") + @@schema("iae") +} + +/// IAE-007, IAE-012: derived versions retain exact source and coordinate lineage. +model ArtifactLineageRecord { + id String @id @db.Uuid + derivedArtifactVersionId String @map("derived_artifact_version_id") @db.Uuid + sourceVersionIds Json @map("source_version_ids") + processorVersion String @map("processor_version") @db.VarChar(128) + recipeVersion String? @map("recipe_version") @db.VarChar(128) + coordinateLineage Json @map("coordinate_lineage") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([derivedArtifactVersionId], map: "artifact_lineage_derived_version_idx") + @@map("artifact_lineage") + @@schema("iae") +} + model ContentPlacement { id String @id @db.Uuid artifactVersionId String @map("artifact_version_id") @db.Uuid diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index c6741166..77d1a12a 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -53,9 +53,14 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "platform"\."schema_registry"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."users"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_versions"/); + assert.match(diff.stdout, /CREATE TABLE "iae"\."inbox_items"/); + assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_lineage"/); assert.match(diff.stdout, /CREATE TABLE "aud"\."audit_events"/); assert.match(diff.stdout, /CREATE TABLE "bua"\."usage_ledger_entries"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_definitions"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "jra"\."jobs"/); assert.match(diff.stdout, /CREATE TABLE "jra"\."execution_attempts"/); assert.match(diff.stdout, /CREATE TABLE "jra"\."result_manifests"/); @@ -76,6 +81,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802070000_jra_result_manifests', '20260802080000_jra_dispatch_outbox', '20260802090000_jra_recipes', + '20260802100000_iae_dsm_governance', 'migration_lock.toml', ]); const migration = await readFile( @@ -205,4 +211,22 @@ test('the schema diff and centrally ordered migration inventory establish platfo ]) { assert.match(recipeMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } + const governanceMigration = await readFile( + path.join(migrationsDirectory, inventory[11], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'ALTER TABLE "iae"."artifact_versions"', + 'CREATE TABLE "iae"."inbox_items"', + 'CREATE TABLE "iae"."artifact_lineage"', + 'ALTER TABLE "dsm"."dataset_definitions"', + 'CREATE TABLE "dsm"."dataset_versions"', + 'CREATE TABLE "dsm"."reference_entity_versions"', + 'CREATE TABLE "dsm"."reference_entity_resolutions"', + ]) { + assert.match( + governanceMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 08b39be41982afd054b15ee56446f72a6e474b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:43:27 +0700 Subject: [PATCH 05/44] feat(api): expose scoped IAE intake and DSM catalog endpoints --- services/api/openapi/v1.json | 289 ++++++++++++++++++ services/api/src/app.module.ts | 11 +- services/api/src/bootstrap.ts | 4 +- .../dsm/api/governed-dataset.controller.ts | 50 +++ .../features/dsm/api/governed-dataset.dto.ts | 87 ++++++ services/api/src/features/dsm/dsm.module.ts | 38 +++ .../src/features/iae/api/inbox-item.dto.ts | 22 ++ .../src/features/iae/api/inbox.controller.ts | 50 +++ services/api/src/features/iae/iae.module.ts | 39 +++ .../http/request-tenant-context.port.ts | 16 + services/api/test/openapi.test.ts | 3 + 11 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 services/api/src/features/dsm/api/governed-dataset.controller.ts create mode 100644 services/api/src/features/dsm/api/governed-dataset.dto.ts create mode 100644 services/api/src/features/dsm/dsm.module.ts create mode 100644 services/api/src/features/iae/api/inbox-item.dto.ts create mode 100644 services/api/src/features/iae/api/inbox.controller.ts create mode 100644 services/api/src/features/iae/iae.module.ts create mode 100644 services/api/src/platform/http/request-tenant-context.port.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index f2acc6cd..cf335d3f 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -469,6 +469,234 @@ "summary": "Sign in and issue a short-lived session", "tags": ["auth"] } + }, + "/v1/artifacts/inbox": { + "post": { + "operationId": "InboxController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Register a content-free artifact intake item", + "tags": ["artifacts"] + } + }, + "/v1/datasets": { + "post": { + "operationId": "GovernedDatasetController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGovernedDatasetDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an immutable governed dataset definition draft", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions": { + "get": { + "operationId": "GovernedDatasetController.list", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List governed dataset versions visible to the caller", + "tags": ["datasets"] + } } }, "info": { @@ -553,6 +781,67 @@ "mfaRequired" ] }, + "CreateInboxItemDto": { + "type": "object", + "properties": { + "inboxItemId": { "type": "string", "format": "uuid" }, + "artifactVersionId": { "type": "string", "format": "uuid" }, + "createdAt": { "type": "string", "format": "date-time" }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["inboxItemId", "artifactVersionId", "createdAt"] + }, + "GovernedDatasetFieldDto": { + "type": "object", + "properties": { + "fieldId": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "maxLength": 128 }, + "type": { + "type": "string", + "enum": ["TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE"] + }, + "nullable": { "type": "boolean" }, + "unit": { "type": "string", "maxLength": 64 }, + "semanticRole": { "type": "string", "maxLength": 128 }, + "aliases": { "type": "array", "items": { "type": "string" } }, + "localizedLabels": { "type": "object" }, + "sensitivity": { + "type": "string", + "enum": ["PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED"] + }, + "defaultBehavior": { + "type": "string", + "enum": ["MISSING", "NULL", "STATIC", "NONE"] + } + }, + "required": ["fieldId", "name", "type", "nullable"] + }, + "CreateGovernedDatasetDto": { + "type": "object", + "properties": { + "datasetId": { "type": "string", "format": "uuid" }, + "versionId": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "maxLength": 200 }, + "fields": { + "type": "array", + "items": { "$ref": "#/components/schemas/GovernedDatasetFieldDto" } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": [ + "datasetId", + "versionId", + "name", + "fields", + "createdAt", + "canonicalHash" + ] + }, "Identifier": { "title": "Stable UUID Identifier", "description": "An opaque stable UUID identifier.", diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 835cab36..fe9d10c9 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -2,15 +2,22 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { IamModule, type IamModuleOptions } from './features/iam/iam.module.js'; import { SystemModule, type SystemModuleOptions } from './features/system/system.module.js'; +import { IaeModule, type IaeModuleOptions } from './features/iae/iae.module.js'; +import { DsmModule, type DsmModuleOptions } from './features/dsm/dsm.module.js'; -export type AppModuleOptions = SystemModuleOptions & IamModuleOptions; +export type AppModuleOptions = SystemModuleOptions & IamModuleOptions & IaeModuleOptions & DsmModuleOptions; @Module({}) export class AppModule { static register(options: AppModuleOptions = {}): DynamicModule { return { module: AppModule, - imports: [SystemModule.register(options), IamModule.register(options)], + imports: [ + SystemModule.register(options), + IamModule.register(options), + IaeModule.register(options), + DsmModule.register(options), + ], }; } } diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 03c1eb92..fc7be51f 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -6,6 +6,8 @@ import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fa import { AppModule } from './app.module.js'; import type { IamModuleOptions } from './features/iam/iam.module.js'; +import type { IaeModuleOptions } from './features/iae/iae.module.js'; +import type { DsmModuleOptions } from './features/dsm/dsm.module.js'; import type { ClientCompatibilityPort } from './features/system/application/client-compatibility.port.js'; import type { ReadinessPort } from './features/system/application/readiness.port.js'; import { ProblemDetailsFilter } from './platform/http/problem-details.filter.js'; @@ -18,7 +20,7 @@ export interface ApiApplication { readonly openApi: OpenAPIObject | object; } -export interface ApiApplicationOptions extends IamModuleOptions { +export interface ApiApplicationOptions extends IamModuleOptions, IaeModuleOptions, DsmModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; } diff --git a/services/api/src/features/dsm/api/governed-dataset.controller.ts b/services/api/src/features/dsm/api/governed-dataset.controller.ts new file mode 100644 index 00000000..25d57558 --- /dev/null +++ b/services/api/src/features/dsm/api/governed-dataset.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + GOVERNED_DATASET_REPOSITORY_PORT, + type GovernedDatasetRepositoryPortV1, +} from '../application/governed-dataset-repository.port.js'; +import { GovernedDatasetService } from '../application/governed-dataset.service.js'; +import { CreateGovernedDatasetDto } from './governed-dataset.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/datasets') +export class GovernedDatasetController { + private readonly datasets: GovernedDatasetService; + + public constructor( + @Inject(GOVERNED_DATASET_REPOSITORY_PORT) + repository: GovernedDatasetRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) { + this.datasets = new GovernedDatasetService(repository); + } + + @Post() + @ApiOperation({ summary: 'Create an immutable governed dataset definition draft' }) + @ApiBody({ type: CreateGovernedDatasetDto }) + async create(@Req() request: unknown, @Body() input: CreateGovernedDatasetDto): Promise { + const context = await this.requestContext.resolve(request); + return this.datasets.create(context, { + ...input, + tenantScope: context.tenantScope, + }); + } + + @Get(':datasetId/versions') + @ApiOperation({ summary: 'List governed dataset versions visible to the caller' }) + async list(@Req() request: unknown, @Param('datasetId') datasetIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.datasets.list(context, datasetId.value); + } +} diff --git a/services/api/src/features/dsm/api/governed-dataset.dto.ts b/services/api/src/features/dsm/api/governed-dataset.dto.ts new file mode 100644 index 00000000..7f7fa1a2 --- /dev/null +++ b/services/api/src/features/dsm/api/governed-dataset.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested } from 'class-validator'; + +export class GovernedDatasetFieldDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + fieldId!: string; + + @ApiProperty({ maxLength: 128 }) + @IsString() + @MinLength(1) + @MaxLength(128) + name!: string; + + @ApiProperty({ enum: ['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'] }) + @IsIn(['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE']) + type!: 'TEXT' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'DATE'; + + @ApiProperty() + @IsBoolean() + nullable!: boolean; + + @ApiPropertyOptional({ maxLength: 64 }) + @IsOptional() + @IsString() + @MaxLength(64) + unit?: string; + + @ApiPropertyOptional({ maxLength: 128 }) + @IsOptional() + @IsString() + @MaxLength(128) + semanticRole?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + aliases?: string[]; + + @ApiPropertyOptional({ type: Object }) + @IsOptional() + localizedLabels?: Record; + + @ApiPropertyOptional({ enum: ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL', 'RESTRICTED'] }) + @IsOptional() + @IsIn(['PUBLIC', 'INTERNAL', 'CONFIDENTIAL', 'RESTRICTED']) + sensitivity?: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' | 'RESTRICTED'; + + @ApiPropertyOptional({ enum: ['MISSING', 'NULL', 'STATIC', 'NONE'] }) + @IsOptional() + @IsIn(['MISSING', 'NULL', 'STATIC', 'NONE']) + defaultBehavior?: 'MISSING' | 'NULL' | 'STATIC' | 'NONE'; +} + +export class CreateGovernedDatasetDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + versionId!: string; + + @ApiProperty({ maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; + + @ApiProperty({ type: [GovernedDatasetFieldDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => GovernedDatasetFieldDto) + fields!: GovernedDatasetFieldDto[]; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @MinLength(64) + @MaxLength(64) + canonicalHash!: string; +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts new file mode 100644 index 00000000..523f4221 --- /dev/null +++ b/services/api/src/features/dsm/dsm.module.ts @@ -0,0 +1,38 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { GovernedDatasetController } from './api/governed-dataset.controller.js'; +import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; +import { + GOVERNED_DATASET_REPOSITORY_PORT, + type GovernedDatasetRepositoryPortV1, +} from './application/governed-dataset-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export interface DsmModuleOptions { + readonly governedDatasetRepository?: GovernedDatasetRepositoryPortV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class DsmModule { + public static register(options: DsmModuleOptions = {}): DynamicModule { + return { + module: DsmModule, + controllers: [GovernedDatasetController], + providers: [ + { + provide: GOVERNED_DATASET_REPOSITORY_PORT, + useValue: options.governedDatasetRepository ?? new InMemoryGovernedDatasetRepositoryAdapter(), + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + }; + } +} diff --git a/services/api/src/features/iae/api/inbox-item.dto.ts b/services/api/src/features/iae/api/inbox-item.dto.ts new file mode 100644 index 00000000..c00246fd --- /dev/null +++ b/services/api/src/features/iae/api/inbox-item.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601, IsUUID, MaxLength, MinLength } from 'class-validator'; + +/** IAE-001: content-free, idempotent intake registration request. */ +export class CreateInboxItemDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + inboxItemId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + artifactVersionId!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ minLength: 1, maxLength: 200, required: false }) + @MaxLength(200) + @MinLength(1) + idempotencyKey?: string; +} diff --git a/services/api/src/features/iae/api/inbox.controller.ts b/services/api/src/features/iae/api/inbox.controller.ts new file mode 100644 index 00000000..3d619dc5 --- /dev/null +++ b/services/api/src/features/iae/api/inbox.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Headers, Inject, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ARTIFACT_INTAKE_REPOSITORY_PORT, + type ArtifactIntakeRepositoryPortV1, +} from '../application/artifact-intake-repository.port.js'; +import { + ArtifactIntakeService, + type ArtifactIntakeServiceResultV1, +} from '../application/artifact-intake.service.js'; +import { CreateInboxItemDto } from './inbox-item.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifacts') +export class InboxController { + private readonly intake: ArtifactIntakeService; + + public constructor( + @Inject(ARTIFACT_INTAKE_REPOSITORY_PORT) + repository: ArtifactIntakeRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) { + this.intake = new ArtifactIntakeService(repository); + } + + @Post('inbox') + @ApiOperation({ summary: 'Register a content-free artifact intake item' }) + @ApiBody({ type: CreateInboxItemDto }) + async create( + @Req() request: unknown, + @Headers('idempotency-key') idempotencyKey: string | undefined, + @Body() input: CreateInboxItemDto, + ): Promise> { + const context = await this.requestContext.resolve(request); + return this.intake.create(context, { + inboxItemId: input.inboxItemId, + tenantScope: context.tenantScope, + idempotencyKey: idempotencyKey ?? input.idempotencyKey ?? context.idempotencyKey, + artifactVersionId: input.artifactVersionId, + createdAt: input.createdAt, + }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts new file mode 100644 index 00000000..0799fd0e --- /dev/null +++ b/services/api/src/features/iae/iae.module.ts @@ -0,0 +1,39 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { InboxController } from './api/inbox.controller.js'; +import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; +import { + ARTIFACT_INTAKE_REPOSITORY_PORT, + type ArtifactIntakeRepositoryPortV1, +} from './application/artifact-intake-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export interface IaeModuleOptions { + readonly artifactIntakeRepository?: ArtifactIntakeRepositoryPortV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class IaeModule { + public static register(options: IaeModuleOptions = {}): DynamicModule { + return { + module: IaeModule, + controllers: [InboxController], + providers: [ + { + provide: ARTIFACT_INTAKE_REPOSITORY_PORT, + useValue: options.artifactIntakeRepository ?? new InMemoryArtifactIntakeRepositoryAdapter(), + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [ARTIFACT_INTAKE_REPOSITORY_PORT], + }; + } +} diff --git a/services/api/src/platform/http/request-tenant-context.port.ts b/services/api/src/platform/http/request-tenant-context.port.ts new file mode 100644 index 00000000..5ec4529c --- /dev/null +++ b/services/api/src/platform/http/request-tenant-context.port.ts @@ -0,0 +1,16 @@ +import type { IamTenantContextV1 } from '../../features/iam/application/tenant-context.js'; + +export const REQUEST_TENANT_CONTEXT = Symbol('REQUEST_TENANT_CONTEXT'); + +/** Resolves an already-authenticated request to a scoped IAM context. */ +export interface RequestTenantContextPortV1 { + resolve(request: unknown): Promise; +} + +/** Safe default until the IAM bearer/session adapter is configured by the host. */ +export class UnavailableRequestTenantContextAdapter implements RequestTenantContextPortV1 { + public async resolve(_request: unknown): Promise { + await Promise.resolve(); + throw new Error('AUTHENTICATED_CONTEXT_UNAVAILABLE'); + } +} diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 405008c5..19371e5b 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -63,7 +63,10 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.deepEqual(paths, [ '/health/live', '/health/ready', + '/v1/artifacts/inbox', '/v1/auth/sign-in', + '/v1/datasets', + '/v1/datasets/{datasetId}/versions', '/v1/system/compatibility', '/v1/system/compatibility/check', ]); From 2bf061422f438302c61afb3649918e925346ecdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:45:18 +0700 Subject: [PATCH 06/44] feat(engine): add deterministic dataset profiling primitive --- .../processors/dataset_profile.py | 129 ++++++++++++++++++ services/engine/tests/test_dataset_profile.py | 53 +++++++ 2 files changed, 182 insertions(+) create mode 100644 services/engine/src/databreeze_engine/processors/dataset_profile.py create mode 100644 services/engine/tests/test_dataset_profile.py diff --git a/services/engine/src/databreeze_engine/processors/dataset_profile.py b/services/engine/src/databreeze_engine/processors/dataset_profile.py new file mode 100644 index 00000000..2db18eef --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/dataset_profile.py @@ -0,0 +1,129 @@ +"""Deterministic, content-free dataset profiling primitives (DSM-011, DSM-015).""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr + +ValueState = Literal["MISSING", "NULL", "BLANK", "INVALID", "ZERO", "NOT_APPLICABLE", "REDACTED", "VALUE"] +StateCounts = dict[ValueState, StrictInt] + + +class ProfileFieldSummary(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + field: StrictStr + stateCounts: StateCounts + distinctCount: StrictInt = Field(ge=0) + valueFingerprint: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +class DatasetProfile(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + rowCountScanned: StrictInt = Field(ge=0) + sourceRowCount: StrictInt = Field(ge=0) + sampled: StrictBool + sampleMethod: Literal["HEAD"] + sampleSeed: StrictInt = Field(ge=0) + fields: tuple[ProfileFieldSummary, ...] + + +def _empty_counts() -> StateCounts: + return { + "MISSING": 0, + "NULL": 0, + "BLANK": 0, + "INVALID": 0, + "ZERO": 0, + "NOT_APPLICABLE": 0, + "REDACTED": 0, + "VALUE": 0, + } + + +def _classify(value: object) -> ValueState: + if value is None: + return "NULL" + if isinstance(value, str): + normalized = value.strip() + if normalized == "": + return "BLANK" + if normalized.upper() in {"N/A", "NA", "NOT APPLICABLE"}: + return "NOT_APPLICABLE" + if normalized.upper() in {"[REDACTED]", "REDACTED"}: + return "REDACTED" + return "VALUE" + if isinstance(value, bool): + return "VALUE" + if isinstance(value, (int, float)): + return "ZERO" if value == 0 else "VALUE" + return "INVALID" + + +def _fingerprint(values: Sequence[object]) -> str: + digests: list[str] = [] + for value in values: + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + except (TypeError, ValueError): + encoded = repr(type(value)).encode("ascii") + digests.append(hashlib.sha256(encoded).hexdigest()) + canonical = "|".join(sorted(set(digests))).encode("ascii") + return hashlib.sha256(canonical).hexdigest() + + +def profile_records( + rows: Sequence[Mapping[str, object]], + fields: Sequence[str], + *, + max_rows: int = 100_000, + sample_seed: int = 0, +) -> DatasetProfile: + """Profile bounded rows without returning source values or row samples.""" + if not fields or len(set(fields)) != len(fields): + raise ValueError("fields must be unique and non-empty") + if max_rows < 1 or sample_seed < 0: + raise ValueError("profile bounds are invalid") + if any(not field or len(field) > 128 for field in fields): + raise ValueError("field names are invalid") + selected = rows[:max_rows] + summaries: list[ProfileFieldSummary] = [] + for field in fields: + counts = _empty_counts() + values: list[object] = [] + for row in selected: + if field not in row: + counts["MISSING"] += 1 + continue + value = row[field] + state = _classify(value) + counts[state] += 1 + if state in {"VALUE", "ZERO"}: + values.append(value) + summaries.append( + ProfileFieldSummary( + field=field, + stateCounts=counts, + distinctCount=len({_fingerprint([value]) for value in values}), + valueFingerprint=_fingerprint(values), + ) + ) + return DatasetProfile( + rowCountScanned=len(selected), + sourceRowCount=len(rows), + sampled=len(selected) < len(rows), + sampleMethod="HEAD", + sampleSeed=sample_seed, + fields=tuple(summaries), + ) diff --git a/services/engine/tests/test_dataset_profile.py b/services/engine/tests/test_dataset_profile.py new file mode 100644 index 00000000..dcede708 --- /dev/null +++ b/services/engine/tests/test_dataset_profile.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import math + +import pytest + +from databreeze_engine.processors.dataset_profile import profile_records + + +def test_profile_distinguishes_missing_null_blank_zero_and_not_applicable() -> None: + result = profile_records( + [ + {"amount": 0, "status": "ok"}, + {"amount": None, "status": "N/A"}, + {"amount": " ", "status": "[REDACTED]"}, + {"status": "ok"}, + ], + ["amount", "status"], + ) + amount = result.fields[0] + status = result.fields[1] + assert amount.stateCounts == { + "MISSING": 1, + "NULL": 1, + "BLANK": 1, + "INVALID": 0, + "ZERO": 1, + "NOT_APPLICABLE": 0, + "REDACTED": 0, + "VALUE": 0, + } + assert status.stateCounts["NOT_APPLICABLE"] == 1 + assert status.stateCounts["REDACTED"] == 1 + assert result.sampled is False + assert "ok" not in status.valueFingerprint + + +def test_profile_is_deterministic_and_discloses_sampling() -> None: + rows = [{"code": "B"}, {"code": "A"}, {"code": "B"}] + first = profile_records(rows, ["code"], max_rows=2, sample_seed=7) + second = profile_records([{"code": "C"}, {"code": "A"}, {"code": "B"}], ["code"], max_rows=2, sample_seed=7) + assert first.rowCountScanned == 2 + assert first.sourceRowCount == 3 + assert first.sampled is True + assert first.sampleSeed == 7 + assert first.fields[0].valueFingerprint != second.fields[0].valueFingerprint + + +def test_profile_rejects_unbounded_or_non_finite_values_without_leaking_them() -> None: + result = profile_records([{"amount": math.nan}], ["amount"]) + assert result.fields[0].stateCounts["VALUE"] == 1 + with pytest.raises(ValueError): + profile_records([], []) From 66d34b7c18caaea2a3e19d8cd9107dc0be5d1e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:46:03 +0700 Subject: [PATCH 07/44] feat(iae): validate evidence coordinates against source geometry --- packages/domain/src/artifact/v1.ts | 52 +++++++++++++++++++++++ packages/domain/test/artifact-v1.test.mjs | 25 +++++++++++ 2 files changed, 77 insertions(+) diff --git a/packages/domain/src/artifact/v1.ts b/packages/domain/src/artifact/v1.ts index 60fad980..d1732b48 100644 --- a/packages/domain/src/artifact/v1.ts +++ b/packages/domain/src/artifact/v1.ts @@ -17,6 +17,18 @@ export type ArtifactVersionStatusV1 = 'QUARANTINED' | 'ACTIVE' | 'DELETED'; export type ArtifactPlacementKindV1 = 'LOCAL' | 'CLOUD'; export type EvidenceSourceStateV1 = 'AVAILABLE' | 'SOURCE_OFFLINE' | 'DELETED'; +export type EvidenceGeometryV1 = + | { + readonly kind: 'SPREADSHEET'; + readonly sheets: readonly { + readonly name: string; + readonly maxRow: number; + readonly maxColumn: number; + }[]; + } + | { readonly kind: 'PAGED'; readonly maxPage: number } + | { readonly kind: 'TABULAR'; readonly maxRow: number }; + export interface ArtifactVersionV1 { readonly schemaVersion: typeof ARTIFACT_SCHEMA_VERSION_V1; readonly artifactId: StableIdentifierV1; @@ -74,6 +86,7 @@ export type ArtifactErrorCodeV1 = | 'INVALID_REVISION' | 'INVALID_REFERENCE' | 'INVALID_COORDINATE' + | 'COORDINATE_OUT_OF_BOUNDS' | 'INVALID_SOURCE_STATE' | 'CONTENT_MISMATCH' | 'LOCAL_CONTENT_LEAK'; @@ -291,11 +304,45 @@ function evidenceCoordinate(input: unknown): EvidenceCoordinateV1 | undefined { return undefined; } +function spreadsheetColumnNumber(value: string): number { + let result = 0; + for (const character of value) result = result * 26 + character.charCodeAt(0) - 64; + return result; +} + +/** IAE-006: evidence coordinates are checked against the exact source geometry. */ +export function validateEvidenceCoordinateV1( + coordinate: EvidenceCoordinateV1, + geometry?: EvidenceGeometryV1, +): ArtifactResultV1 { + if (!geometry) return accepted(true); + if (coordinate.kind === 'CELL') { + if (geometry.kind !== 'SPREADSHEET') return rejected('COORDINATE_OUT_OF_BOUNDS'); + const sheet = geometry.sheets.find((candidate) => candidate.name === coordinate.sheet); + const address = /^\$?([A-Z]{1,3})\$?([1-9][0-9]*)$/u.exec(coordinate.address.toUpperCase()); + if (!sheet || !address) return rejected('COORDINATE_OUT_OF_BOUNDS'); + const column = spreadsheetColumnNumber(address[1] ?? ''); + const row = Number(address[2]); + return row <= sheet.maxRow && column <= sheet.maxColumn + ? accepted(true) + : rejected('COORDINATE_OUT_OF_BOUNDS'); + } + if (coordinate.kind === 'PAGE') { + return geometry.kind === 'PAGED' && coordinate.page <= geometry.maxPage + ? accepted(true) + : rejected('COORDINATE_OUT_OF_BOUNDS'); + } + return geometry.kind === 'TABULAR' && coordinate.row <= geometry.maxRow + ? accepted(true) + : rejected('COORDINATE_OUT_OF_BOUNDS'); +} + export function createEvidenceReferenceV1(input: { readonly evidenceId: unknown; readonly artifactVersion: ArtifactVersionV1; readonly tenantScope: unknown; readonly coordinate: unknown; + readonly geometry?: unknown; readonly sourceState?: unknown; readonly excerpt?: unknown; }): ArtifactResultV1 { @@ -310,6 +357,11 @@ export function createEvidenceReferenceV1(input: { if (!tenantScopesEqualV1(tenantScope, input.artifactVersion.tenantScope)) return rejected('INVALID_SCOPE'); if (!coordinate) return rejected('INVALID_COORDINATE'); + if (input.geometry !== undefined) { + const geometry = input.geometry as EvidenceGeometryV1; + const coordinateCheck = validateEvidenceCoordinateV1(coordinate, geometry); + if (!coordinateCheck.accepted) return coordinateCheck; + } if (sourceState !== 'AVAILABLE' && sourceState !== 'SOURCE_OFFLINE' && sourceState !== 'DELETED') return rejected('INVALID_SOURCE_STATE'); if (input.excerpt !== undefined && !excerpt) return rejected('INVALID_REFERENCE'); diff --git a/packages/domain/test/artifact-v1.test.mjs b/packages/domain/test/artifact-v1.test.mjs index 78f5e22a..02f03f24 100644 --- a/packages/domain/test/artifact-v1.test.mjs +++ b/packages/domain/test/artifact-v1.test.mjs @@ -5,6 +5,7 @@ import { createArtifactVersionV1, createContentPlacementV1, createEvidenceReferenceV1, + validateEvidenceCoordinateV1, } from '../dist/artifact/v1.js'; const scope = { @@ -92,3 +93,27 @@ void test('[IAE-005, IAE-006] Local evidence carries coordinates but never excer 'LOCAL_CONTENT_LEAK', ); }); + +void test('[IAE-006] evidence coordinates are validated against exact source geometry', () => { + assert.deepEqual( + validateEvidenceCoordinateV1( + { kind: 'CELL', sheet: 'Sheet1', address: 'B4' }, + { kind: 'SPREADSHEET', sheets: [{ name: 'Sheet1', maxRow: 10, maxColumn: 3 }] }, + ), + { accepted: true, value: true }, + ); + assert.deepEqual( + validateEvidenceCoordinateV1( + { kind: 'CELL', sheet: 'Sheet1', address: 'D4' }, + { kind: 'SPREADSHEET', sheets: [{ name: 'Sheet1', maxRow: 10, maxColumn: 3 }] }, + ), + { accepted: false, code: 'COORDINATE_OUT_OF_BOUNDS' }, + ); + assert.deepEqual( + validateEvidenceCoordinateV1( + { kind: 'PAGE', page: 4 }, + { kind: 'PAGED', maxPage: 3 }, + ), + { accepted: false, code: 'COORDINATE_OUT_OF_BOUNDS' }, + ); +}); From 83b480eecfe897a410c1b06d052eca82b0c0a38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:47:38 +0700 Subject: [PATCH 08/44] feat(iae): add lineage and retention decision contracts --- packages/domain/package.json | 4 + packages/domain/src/artifact-governance/v1.ts | 189 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../test/artifact-governance-v1.test.mjs | 62 ++++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 1 + 6 files changed, 260 insertions(+) create mode 100644 packages/domain/src/artifact-governance/v1.ts create mode 100644 packages/domain/test/artifact-governance-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 6f0338eb..ad3de5d6 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -52,6 +52,10 @@ "types": "./src/artifact-intake/v1.ts", "import": "./dist/artifact-intake/v1.js" }, + "./artifact-governance/v1": { + "types": "./src/artifact-governance/v1.ts", + "import": "./dist/artifact-governance/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/artifact-governance/v1.ts b/packages/domain/src/artifact-governance/v1.ts new file mode 100644 index 00000000..bd09e8e9 --- /dev/null +++ b/packages/domain/src/artifact-governance/v1.ts @@ -0,0 +1,189 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAE-003, IAE-007, IAE-012, IAE-021: lineage and deletion authority. */ +export const ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1 = 1 as const; + +export type LineageTransformV1 = 'COPIED' | 'NORMALIZED' | 'AGGREGATED' | 'REDACTED'; +export type RetentionBlockerV1 = + | 'WORKSPACE_RETENTION' + | 'RESOURCE_RETENTION' + | 'AUDIT_RETENTION' + | 'RECOVERY_WINDOW' + | 'ACTIVE_APPROVAL' + | 'LEGAL_HOLD'; + +export interface CoordinateLineageV1 { + readonly sourceEvidenceId: StableIdentifierV1; + readonly derivedEvidenceId: StableIdentifierV1; + readonly transform: LineageTransformV1; +} + +export interface ArtifactLineageV1 { + readonly schemaVersion: typeof ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1; + readonly lineageId: StableIdentifierV1; + readonly derivedArtifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly sourceArtifactVersionIds: readonly StableIdentifierV1[]; + readonly processorVersion: string; + readonly recipeVersion?: string; + readonly coordinateLineage: readonly CoordinateLineageV1[]; +} + +export interface ArtifactRetentionEvaluationV1 { + readonly eligible: boolean; + readonly blockers: readonly RetentionBlockerV1[]; + readonly evaluatedAt: StrictUtcTimestampV1; +} + +export type ArtifactGovernanceErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'CROSS_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_TEXT' + | 'INVALID_HASH' + | 'INVALID_LINEAGE' + | 'DUPLICATE_IDENTIFIER' + | 'INVALID_TRANSFORM'; + +export type ArtifactGovernanceResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactGovernanceErrorCodeV1 }; + +function accepted(value: TValue): ArtifactGovernanceResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactGovernanceErrorCodeV1): ArtifactGovernanceResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const result = parseTenantScopeV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +export function createArtifactLineageV1(input: { + readonly lineageId: unknown; + readonly derivedArtifactVersionId: unknown; + readonly tenantScope: unknown; + readonly sourceArtifactVersionIds: unknown; + readonly processorVersion: unknown; + readonly recipeVersion?: unknown; + readonly coordinateLineage: unknown; + readonly sourceTenantScopes?: unknown; +}): ArtifactGovernanceResultV1 { + const lineageId = identifier(input.lineageId); + const derivedArtifactVersionId = identifier(input.derivedArtifactVersionId); + const tenantScope = scope(input.tenantScope); + const processorVersion = text(input.processorVersion, 128); + const recipeVersion = + input.recipeVersion === undefined ? undefined : text(input.recipeVersion, 128); + if (!lineageId || !derivedArtifactVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!processorVersion || (input.recipeVersion !== undefined && !recipeVersion)) + return rejected('INVALID_TEXT'); + if (!Array.isArray(input.sourceArtifactVersionIds) || input.sourceArtifactVersionIds.length === 0) + return rejected('INVALID_LINEAGE'); + const sourceArtifactVersionIds = input.sourceArtifactVersionIds.map(identifier); + if (sourceArtifactVersionIds.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_IDENTIFIER'); + if (new Set(sourceArtifactVersionIds).size !== sourceArtifactVersionIds.length) + return rejected('DUPLICATE_IDENTIFIER'); + if (input.sourceTenantScopes !== undefined) { + if (!Array.isArray(input.sourceTenantScopes)) return rejected('INVALID_SCOPE'); + for (const candidate of input.sourceTenantScopes) { + const sourceScope = scope(candidate); + if (!sourceScope || !tenantScopesEqualV1(sourceScope, tenantScope)) + return rejected('CROSS_SCOPE'); + } + } + if (!Array.isArray(input.coordinateLineage)) return rejected('INVALID_LINEAGE'); + const coordinateLineage: CoordinateLineageV1[] = []; + for (const candidate of input.coordinateLineage) { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) + return rejected('INVALID_LINEAGE'); + const record = candidate as Record; + const sourceEvidenceId = identifier(record['sourceEvidenceId']); + const derivedEvidenceId = identifier(record['derivedEvidenceId']); + const transform = record['transform']; + if (!sourceEvidenceId || !derivedEvidenceId) return rejected('INVALID_IDENTIFIER'); + if (!['COPIED', 'NORMALIZED', 'AGGREGATED', 'REDACTED'].includes(transform as string)) + return rejected('INVALID_TRANSFORM'); + coordinateLineage.push( + Object.freeze({ + sourceEvidenceId, + derivedEvidenceId, + transform: transform as LineageTransformV1, + }), + ); + } + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, + lineageId, + derivedArtifactVersionId, + tenantScope, + sourceArtifactVersionIds: Object.freeze(sourceArtifactVersionIds as StableIdentifierV1[]), + processorVersion, + ...(recipeVersion ? { recipeVersion } : {}), + coordinateLineage: Object.freeze(coordinateLineage), + }), + ); +} + +export function evaluateArtifactRetentionV1(input: { + readonly evaluatedAt: unknown; + readonly workspaceRetentionUntil: unknown; + readonly resourceRetentionUntil: unknown; + readonly auditRetentionUntil: unknown; + readonly recoveryWindowUntil: unknown; + readonly activeApproval: boolean; + readonly legalHold: boolean; +}): ArtifactGovernanceResultV1 { + const evaluatedAt = timestamp(input.evaluatedAt); + const workspaceRetentionUntil = timestamp(input.workspaceRetentionUntil); + const resourceRetentionUntil = timestamp(input.resourceRetentionUntil); + const auditRetentionUntil = timestamp(input.auditRetentionUntil); + const recoveryWindowUntil = timestamp(input.recoveryWindowUntil); + if (!evaluatedAt || !workspaceRetentionUntil || !resourceRetentionUntil || !auditRetentionUntil || !recoveryWindowUntil) + return rejected('INVALID_TIMESTAMP'); + if (typeof input.activeApproval !== 'boolean' || typeof input.legalHold !== 'boolean') + return rejected('INVALID_LINEAGE'); + const now = Date.parse(evaluatedAt); + const blockers: RetentionBlockerV1[] = []; + if (now < Date.parse(workspaceRetentionUntil)) blockers.push('WORKSPACE_RETENTION'); + if (now < Date.parse(resourceRetentionUntil)) blockers.push('RESOURCE_RETENTION'); + if (now < Date.parse(auditRetentionUntil)) blockers.push('AUDIT_RETENTION'); + if (now < Date.parse(recoveryWindowUntil)) blockers.push('RECOVERY_WINDOW'); + if (input.activeApproval) blockers.push('ACTIVE_APPROVAL'); + if (input.legalHold) blockers.push('LEGAL_HOLD'); + return accepted( + Object.freeze({ eligible: blockers.length === 0, blockers: Object.freeze(blockers), evaluatedAt }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 17a0740b..0e725ec8 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -2,6 +2,7 @@ export * from './authorization/v1.js'; export * from './audit/v1.js'; export * from './artifact/v1.js'; export * from './artifact-intake/v1.js'; +export * from './artifact-governance/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; diff --git a/packages/domain/test/artifact-governance-v1.test.mjs b/packages/domain/test/artifact-governance-v1.test.mjs new file mode 100644 index 00000000..2405a02f --- /dev/null +++ b/packages/domain/test/artifact-governance-v1.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createArtifactLineageV1, evaluateArtifactRetentionV1 } from '../dist/artifact-governance/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; + +void test('[IAE-003, IAE-007, IAE-012] lineage pins source versions and typed transformations', () => { + const result = createArtifactLineageV1({ + lineageId: '00000000-0000-4000-8000-000000000010', + derivedArtifactVersionId: '00000000-0000-4000-8000-000000000011', + tenantScope: scope, + sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000012'], + processorVersion: 'spreadsheet-auditor@1.0.0', + coordinateLineage: [ + { + sourceEvidenceId: '00000000-0000-4000-8000-000000000013', + derivedEvidenceId: '00000000-0000-4000-8000-000000000014', + transform: 'NORMALIZED', + }, + ], + sourceTenantScopes: [scope], + }); + assert.equal(result.accepted, true); + if (result.accepted) assert.equal(result.value.coordinateLineage[0]?.transform, 'NORMALIZED'); +}); + +void test('[IAE-021] deletion eligibility is blocked by the strictest retention and governance condition', () => { + const blocked = evaluateArtifactRetentionV1({ + evaluatedAt: '2026-01-01T00:00:00.000Z', + workspaceRetentionUntil: '2026-01-02T00:00:00.000Z', + resourceRetentionUntil: '2025-12-01T00:00:00.000Z', + auditRetentionUntil: '2025-12-01T00:00:00.000Z', + recoveryWindowUntil: '2025-12-01T00:00:00.000Z', + activeApproval: true, + legalHold: false, + }); + assert.equal(blocked.accepted, true); + if (!blocked.accepted) return; + assert.deepEqual(blocked.value.blockers, ['WORKSPACE_RETENTION', 'ACTIVE_APPROVAL']); + const eligible = evaluateArtifactRetentionV1({ + evaluatedAt: '2026-01-03T00:00:00.000Z', + workspaceRetentionUntil: '2026-01-02T00:00:00.000Z', + resourceRetentionUntil: '2025-12-01T00:00:00.000Z', + auditRetentionUntil: '2025-12-01T00:00:00.000Z', + recoveryWindowUntil: '2025-12-01T00:00:00.000Z', + activeApproval: false, + legalHold: false, + }); + assert.deepEqual(eligible, { + accepted: true, + value: { + eligible: true, + blockers: [], + evaluatedAt: '2026-01-03T00:00:00.000Z', + }, + }); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index c2e71810..1f5ac572 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -7,6 +7,7 @@ const [ authorization, artifact, artifactIntake, + artifactGovernance, dataset, datasetGovernance, dataMode, @@ -25,6 +26,7 @@ const [ import('@databreeze/domain/authorization/v1'), import('@databreeze/domain/artifact/v1'), import('@databreeze/domain/artifact-intake/v1'), + import('@databreeze/domain/artifact-governance/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), @@ -45,6 +47,7 @@ assert.equal(typeof tenantScope.parseTenantScopeV1, 'function'); assert.equal(typeof authorization.createScopedAuthorizationEvaluatorV1, 'function'); assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); + assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index a878426f..e492c059 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -21,6 +21,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './data-mode/v1', './artifact/v1', './artifact-intake/v1', + './artifact-governance/v1', './dataset/v1', './dataset-governance/v1', './jobs/v1', From 11a40230555b7cec98a188af06097c54cac6ffa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:52:16 +0700 Subject: [PATCH 09/44] feat(iae): persist scoped artifact lineage and retention decisions --- ...ory-artifact-lineage-repository.adapter.ts | 93 +++++++++++++++ .../artifact-governance.service.ts | 62 ++++++++++ .../artifact-lineage-repository.port.ts | 27 +++++ .../iae/artifact-governance.service.test.ts | 107 ++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts create mode 100644 services/api/src/features/iae/application/artifact-governance.service.ts create mode 100644 services/api/src/features/iae/application/artifact-lineage-repository.port.ts create mode 100644 services/api/test/features/iae/artifact-governance.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts new file mode 100644 index 00000000..8897e441 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts @@ -0,0 +1,93 @@ +import { + tenantScopeContainsV1, + type ArtifactLineageV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactLineageRepositoryPortV1, + ArtifactLineageTransactionPortV1, +} from '../application/artifact-lineage-repository.port.js'; + +function visible(context: TenantScopeV1, record: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, record) || tenantScopeContainsV1(record, context); +} + +function clone(lineage: ArtifactLineageV1): ArtifactLineageV1 { + return Object.freeze({ + ...lineage, + tenantScope: Object.freeze({ ...lineage.tenantScope }), + sourceArtifactVersionIds: Object.freeze([...lineage.sourceArtifactVersionIds]), + coordinateLineage: Object.freeze(lineage.coordinateLineage.map((item) => Object.freeze({ ...item }))), + }); +} + +/** In-memory governance adapter used until the PostgreSQL repository is wired. */ +export class InMemoryArtifactLineageRepositoryAdapter implements ArtifactLineageRepositoryPortV1 { + private lineages = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, lineage: ArtifactLineageV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, lineage.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.lineages.get(lineage.lineageId); + if (existing && JSON.stringify(existing) !== JSON.stringify(lineage)) + throw new Error('IAE_IMMUTABLE_LINEAGE'); + this.lineages.set(lineage.lineageId, clone(lineage)); + } + + public async findByDerived( + context: IamTenantContextV1, + derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], + ): Promise { + await Promise.resolve(); + const record = [...this.lineages.values()].find( + (candidate) => + candidate.derivedArtifactVersionId === derivedArtifactVersionId && + visible(context.tenantScope, candidate.tenantScope), + ); + return record ? clone(record) : undefined; + } + + public async listBySource( + context: IamTenantContextV1, + sourceArtifactVersionId: ArtifactLineageV1['sourceArtifactVersionIds'][number], + ): Promise { + await Promise.resolve(); + return [...this.lineages.values()] + .filter( + (candidate) => + candidate.sourceArtifactVersionIds.includes(sourceArtifactVersionId) && + visible(context.tenantScope, candidate.tenantScope), + ) + .sort((left, right) => left.lineageId.localeCompare(right.lineageId)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactLineageTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.lineages); + try { + return await work({ + save: this.save.bind(this), + findByDerived: this.findByDerived.bind(this), + listBySource: this.listBySource.bind(this), + }); + } catch (error) { + this.lineages = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/application/artifact-governance.service.ts b/services/api/src/features/iae/application/artifact-governance.service.ts new file mode 100644 index 00000000..1eedcd8b --- /dev/null +++ b/services/api/src/features/iae/application/artifact-governance.service.ts @@ -0,0 +1,62 @@ +import { + createArtifactLineageV1, + evaluateArtifactRetentionV1, + type ArtifactLineageV1, + type ArtifactRetentionEvaluationV1, + type ArtifactGovernanceResultV1, +} from '@databreeze/domain/artifact-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactLineageRepositoryPortV1 } from './artifact-lineage-repository.port.js'; + +export type ArtifactGovernanceServiceErrorV1 = 'LINEAGE_NOT_FOUND'; +export type ArtifactGovernanceServiceResultV1 = + | ArtifactGovernanceResultV1 + | { readonly accepted: false; readonly code: ArtifactGovernanceServiceErrorV1 }; + +/** Coordinates immutable lineage persistence while keeping retention a pure policy decision. */ +export class ArtifactGovernanceService { + public constructor(private readonly repository: ArtifactLineageRepositoryPortV1) {} + + public async registerLineage( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createArtifactLineageV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.findByDerived(context, created.value.derivedArtifactVersionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true as const, value: existing }); + throw new Error('IAE_DERIVED_LINEAGE_CONFLICT'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async findForDerived( + context: IamTenantContextV1, + derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.findByDerived(context, derivedArtifactVersionId), + ); + } + + public async listForSource( + context: IamTenantContextV1, + sourceArtifactVersionId: ArtifactLineageV1['sourceArtifactVersionIds'][number], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.listBySource(context, sourceArtifactVersionId), + ); + } + + public evaluateRetention( + input: Parameters[0], + ): ArtifactGovernanceResultV1 { + return evaluateArtifactRetentionV1(input); + } +} diff --git a/services/api/src/features/iae/application/artifact-lineage-repository.port.ts b/services/api/src/features/iae/application/artifact-lineage-repository.port.ts new file mode 100644 index 00000000..5d925936 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-lineage-repository.port.ts @@ -0,0 +1,27 @@ +import type { ArtifactLineageV1 } from '@databreeze/domain/artifact-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_LINEAGE_REPOSITORY_PORT = Symbol('ARTIFACT_LINEAGE_REPOSITORY_PORT'); + +export interface ArtifactLineageTransactionPortV1 { + save( + context: IamTenantContextV1, + lineage: ArtifactLineageV1, + ): Promise; + findByDerived( + context: IamTenantContextV1, + derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], + ): Promise; + listBySource( + context: IamTenantContextV1, + sourceArtifactVersionId: ArtifactLineageV1['sourceArtifactVersionIds'][number], + ): Promise; +} + +export interface ArtifactLineageRepositoryPortV1 extends ArtifactLineageTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactLineageTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/test/features/iae/artifact-governance.service.test.ts b/services/api/test/features/iae/artifact-governance.service.test.ts new file mode 100644 index 00000000..26d47d2b --- /dev/null +++ b/services/api/test/features/iae/artifact-governance.service.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryArtifactLineageRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { ArtifactGovernanceService } from '../../../src/features/iae/application/artifact-governance.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000003'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; + +function context(workspace: string, idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid identifier'); + return result.value; +} + +const input = { + lineageId: '00000000-0000-4000-8000-000000000020', + derivedArtifactVersionId: '00000000-0000-4000-8000-000000000021', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000022'], + sourceTenantScopes: [{ scopeType: 'workspace', organizationId, workspaceId }], + processorVersion: 'spreadsheet-auditor@1', + coordinateLineage: [ + { + sourceEvidenceId: '00000000-0000-4000-8000-000000000023', + derivedEvidenceId: '00000000-0000-4000-8000-000000000024', + transform: 'NORMALIZED', + }, + ], +}; +const sourceArtifactVersionId = input.sourceArtifactVersionIds[0] as string; + +void test('[IAE-007, IAE-012] lineage is immutable, idempotent, and tenant scoped', async () => { + const service = new ArtifactGovernanceService(new InMemoryArtifactLineageRepositoryAdapter()); + const created = await service.registerLineage(context(workspaceId, 'lineage-1'), input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const repeated = await service.registerLineage(context(workspaceId, 'lineage-2'), input); + assert.deepEqual(repeated, created); + assert.equal( + (await service.findForDerived(context(siblingWorkspaceId, 'lineage-read'), stable(input.derivedArtifactVersionId))), + undefined, + ); + assert.equal( + (await service.listForSource(context(workspaceId, 'lineage-source'), stable(sourceArtifactVersionId))).length, + 1, + ); +}); + +void test('[IAE-007] lineage rejects cross-scope sources and conflicting derived versions', async () => { + const service = new ArtifactGovernanceService(new InMemoryArtifactLineageRepositoryAdapter()); + const crossScope = await service.registerLineage(context(workspaceId, 'lineage-cross'), { + ...input, + sourceTenantScopes: [{ scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }], + }); + assert.deepEqual(crossScope, { accepted: false, code: 'CROSS_SCOPE' }); + await service.registerLineage(context(workspaceId, 'lineage-conflict-a'), input); + await assert.rejects( + service.registerLineage(context(workspaceId, 'lineage-conflict-b'), { + ...input, + lineageId: '00000000-0000-4000-8000-000000000025', + processorVersion: 'different@1', + }), + /IAE_DERIVED_LINEAGE_CONFLICT/u, + ); +}); + +void test('[IAE-021] retention evaluation aggregates every blocker deterministically', () => { + const service = new ArtifactGovernanceService(new InMemoryArtifactLineageRepositoryAdapter()); + const result = service.evaluateRetention({ + evaluatedAt: '2026-01-01T00:00:00.000Z', + workspaceRetentionUntil: '2026-02-01T00:00:00.000Z', + resourceRetentionUntil: '2025-12-01T00:00:00.000Z', + auditRetentionUntil: '2026-03-01T00:00:00.000Z', + recoveryWindowUntil: '2025-12-01T00:00:00.000Z', + activeApproval: true, + legalHold: true, + }); + assert.deepEqual(result, { + accepted: true, + value: { + eligible: false, + blockers: ['WORKSPACE_RETENTION', 'AUDIT_RETENTION', 'ACTIVE_APPROVAL', 'LEGAL_HOLD'], + evaluatedAt: '2026-01-01T00:00:00.000Z', + }, + }); +}); From 5d8acac97085dc05f15f0480c4ba6a3c1447c249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:55:42 +0700 Subject: [PATCH 10/44] feat(iae): enforce derived artifact data-mode policy --- packages/domain/src/artifact-governance/v1.ts | 30 +++- .../test/artifact-governance-v1.test.mjs | 45 +++++- .../application/derived-artifact.service.ts | 109 ++++++++++++++ .../iae/derived-artifact.service.test.ts | 142 ++++++++++++++++++ 4 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/iae/application/derived-artifact.service.ts create mode 100644 services/api/test/features/iae/derived-artifact.service.test.ts diff --git a/packages/domain/src/artifact-governance/v1.ts b/packages/domain/src/artifact-governance/v1.ts index bd09e8e9..7f57c35f 100644 --- a/packages/domain/src/artifact-governance/v1.ts +++ b/packages/domain/src/artifact-governance/v1.ts @@ -7,6 +7,7 @@ import { type StrictUtcTimestampV1, type TenantScopeV1, } from '../tenant-scope/v1.js'; +import type { ArtifactVersionV1 } from '../artifact/v1.js'; /** IAE-003, IAE-007, IAE-012, IAE-021: lineage and deletion authority. */ export const ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1 = 1 as const; @@ -52,7 +53,10 @@ export type ArtifactGovernanceErrorCodeV1 = | 'INVALID_HASH' | 'INVALID_LINEAGE' | 'DUPLICATE_IDENTIFIER' - | 'INVALID_TRANSFORM'; + | 'INVALID_TRANSFORM' + | 'SOURCE_REQUIRED' + | 'SOURCE_NOT_ACTIVE' + | 'DATA_MODE_WIDENING'; export type ArtifactGovernanceResultV1 = | { readonly accepted: true; readonly value: TValue } @@ -157,6 +161,30 @@ export function createArtifactLineageV1(input: { ); } +/** IAE-007, IAE-008: derived content cannot cross scope or widen a data mode. */ +export function validateDerivedArtifactVersionV1(input: { + readonly derived: ArtifactVersionV1; + readonly sourceVersions: readonly ArtifactVersionV1[]; +}): ArtifactGovernanceResultV1 { + if (input.sourceVersions.length === 0) return rejected('SOURCE_REQUIRED'); + const sourceIds = new Set(); + const sourceModeRanks: number[] = []; + for (const source of input.sourceVersions) { + if (source.versionId === input.derived.versionId) return rejected('DUPLICATE_IDENTIFIER'); + if (sourceIds.has(source.versionId)) return rejected('DUPLICATE_IDENTIFIER'); + sourceIds.add(source.versionId); + if (!tenantScopesEqualV1(source.tenantScope, input.derived.tenantScope)) + return rejected('CROSS_SCOPE'); + if (source.status !== 'ACTIVE') return rejected('SOURCE_NOT_ACTIVE'); + sourceModeRanks.push(source.dataMode === 'Local' ? 0 : source.dataMode === 'Hybrid' ? 1 : 2); + } + const derivedModeRank = input.derived.dataMode === 'Local' ? 0 : input.derived.dataMode === 'Hybrid' ? 1 : 2; + const leastPermissiveSource = Math.min(...sourceModeRanks); + return derivedModeRank <= leastPermissiveSource + ? accepted(true) + : rejected('DATA_MODE_WIDENING'); +} + export function evaluateArtifactRetentionV1(input: { readonly evaluatedAt: unknown; readonly workspaceRetentionUntil: unknown; diff --git a/packages/domain/test/artifact-governance-v1.test.mjs b/packages/domain/test/artifact-governance-v1.test.mjs index 2405a02f..e9114db5 100644 --- a/packages/domain/test/artifact-governance-v1.test.mjs +++ b/packages/domain/test/artifact-governance-v1.test.mjs @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createArtifactLineageV1, evaluateArtifactRetentionV1 } from '../dist/artifact-governance/v1.js'; +import { + createArtifactLineageV1, + evaluateArtifactRetentionV1, + validateDerivedArtifactVersionV1, +} from '../dist/artifact-governance/v1.js'; +import { createArtifactVersionV1 } from '../dist/artifact/v1.js'; const scope = { scopeType: 'workspace', @@ -29,6 +34,44 @@ void test('[IAE-003, IAE-007, IAE-012] lineage pins source versions and typed tr if (result.accepted) assert.equal(result.value.coordinateLineage[0]?.transform, 'NORMALIZED'); }); +void test('[IAE-008] derived data mode cannot be wider than its least-permissive source', () => { + const source = createArtifactVersionV1({ + artifactId: '00000000-0000-4000-8000-000000000020', + versionId: '00000000-0000-4000-8000-000000000021', + tenantScope: scope, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'a'.repeat(64), + byteSize: 1, + mediaType: 'text/csv', + displayName: 'source.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }); + const derived = createArtifactVersionV1({ + artifactId: '00000000-0000-4000-8000-000000000022', + versionId: '00000000-0000-4000-8000-000000000023', + tenantScope: scope, + sourceKind: 'GENERATED', + dataMode: 'Hybrid', + contentSha256: 'b'.repeat(64), + byteSize: 1, + mediaType: 'text/csv', + displayName: 'derived.csv', + createdAt: '2026-01-01T00:00:01.000Z', + }); + assert.equal(source.accepted, true); + assert.equal(derived.accepted, true); + if (!source.accepted || !derived.accepted) return; + assert.deepEqual(validateDerivedArtifactVersionV1({ derived: derived.value, sourceVersions: [source.value] }), { + accepted: false, + code: 'DATA_MODE_WIDENING', + }); + assert.deepEqual(validateDerivedArtifactVersionV1({ + derived: { ...derived.value, dataMode: 'Local' }, + sourceVersions: [source.value], + }), { accepted: true, value: true }); +}); + void test('[IAE-021] deletion eligibility is blocked by the strictest retention and governance condition', () => { const blocked = evaluateArtifactRetentionV1({ evaluatedAt: '2026-01-01T00:00:00.000Z', diff --git a/services/api/src/features/iae/application/derived-artifact.service.ts b/services/api/src/features/iae/application/derived-artifact.service.ts new file mode 100644 index 00000000..df1b2d97 --- /dev/null +++ b/services/api/src/features/iae/application/derived-artifact.service.ts @@ -0,0 +1,109 @@ +import { + createArtifactLineageV1, + validateDerivedArtifactVersionV1, + type ArtifactLineageV1, + type ArtifactGovernanceResultV1, +} from '@databreeze/domain/artifact-governance/v1'; +import { + createArtifactVersionV1, + createContentPlacementV1, + createEvidenceReferenceV1, + type ArtifactResultV1, + type ArtifactVersionV1, + type ContentPlacementV1, + type EvidenceReferenceV1, +} from '@databreeze/domain/artifact/v1'; +import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactLineageRepositoryPortV1 } from './artifact-lineage-repository.port.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; + +export interface DerivedArtifactRegistrationInputV1 { + readonly version: Parameters[0]; + readonly placement: Omit[0], 'artifactVersion'>; + readonly evidence?: Omit[0], 'artifactVersion'>; + readonly sourceArtifactVersionIds: readonly unknown[]; + readonly lineage: Omit< + Parameters[0], + 'sourceArtifactVersionIds' | 'sourceTenantScopes' | 'tenantScope' | 'derivedArtifactVersionId' + >; +} + +export interface DerivedArtifactRegistrationValueV1 { + readonly version: ArtifactVersionV1; + readonly placement: ContentPlacementV1; + readonly evidence?: EvidenceReferenceV1; + readonly lineage: ArtifactLineageV1; +} + +export type DerivedArtifactServiceErrorV1 = 'SOURCE_NOT_FOUND'; +export type DerivedArtifactServiceResultV1 = + | ArtifactResultV1 + | ArtifactGovernanceResultV1 + | { readonly accepted: false; readonly code: DerivedArtifactServiceErrorV1 }; + +/** Registers a derivative only after reading and validating every exact source version. */ +export class DerivedArtifactService { + public constructor( + private readonly artifactRepository: ArtifactRepositoryPortV1, + private readonly lineageRepository: ArtifactLineageRepositoryPortV1, + ) {} + + public async register( + context: IamTenantContextV1, + input: DerivedArtifactRegistrationInputV1, + ): Promise> { + const version = createArtifactVersionV1(input.version); + if (!version.accepted) return version; + const sourceIds: StableIdentifierV1[] = []; + for (const candidate of input.sourceArtifactVersionIds) { + const parsed = parseStableIdentifierV1(candidate); + if (!parsed.accepted) return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); + sourceIds.push(parsed.value); + } + const sourceVersions = await this.artifactRepository.withTransaction(context, async (transaction) => { + const values: ArtifactVersionV1[] = []; + for (const sourceId of sourceIds) { + const source = await transaction.findVersion(context, sourceId); + if (!source) return undefined; + values.push(source); + } + return values; + }); + if (!sourceVersions) return Object.freeze({ accepted: false as const, code: 'SOURCE_NOT_FOUND' as const }); + const policy = validateDerivedArtifactVersionV1({ derived: version.value, sourceVersions }); + if (!policy.accepted) return policy; + const placement = createContentPlacementV1({ ...input.placement, artifactVersion: version.value }); + if (!placement.accepted) return placement; + const evidence = input.evidence + ? createEvidenceReferenceV1({ ...input.evidence, artifactVersion: version.value }) + : undefined; + if (evidence && !evidence.accepted) return evidence; + const lineage = createArtifactLineageV1({ + ...input.lineage, + tenantScope: version.value.tenantScope, + derivedArtifactVersionId: version.value.versionId, + sourceArtifactVersionIds: sourceIds, + sourceTenantScopes: sourceVersions.map((source) => source.tenantScope), + }); + if (!lineage.accepted) return lineage; + return this.artifactRepository.withTransaction(context, async (artifactTransaction) => + this.lineageRepository.withTransaction(context, async (lineageTransaction) => { + await artifactTransaction.saveVersion(context, version.value); + await artifactTransaction.savePlacement(context, placement.value); + if (evidence?.accepted) await artifactTransaction.saveEvidence(context, evidence.value); + await lineageTransaction.save(context, lineage.value); + return Object.freeze({ + accepted: true as const, + value: Object.freeze({ + version: version.value, + placement: placement.value, + ...(evidence?.accepted ? { evidence: evidence.value } : {}), + lineage: lineage.value, + }), + }); + }), + ); + } +} diff --git a/services/api/test/features/iae/derived-artifact.service.test.ts b/services/api/test/features/iae/derived-artifact.service.test.ts new file mode 100644 index 00000000..41361f8d --- /dev/null +++ b/services/api/test/features/iae/derived-artifact.service.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +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 { DerivedArtifactService } from '../../../src/features/iae/application/derived-artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; +const scope = { scopeType: 'workspace' as const, organizationId, workspaceId }; + +function context(idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function sourceInput(dataMode: 'Local' | 'Hybrid' | 'Cloud' = 'Hybrid') { + return { + version: { + artifactId: '00000000-0000-4000-8000-000000000020', + versionId: '00000000-0000-4000-8000-000000000021', + tenantScope: scope, + sourceKind: 'FILE', + dataMode, + contentSha256: 'a'.repeat(64), + byteSize: 1, + mediaType: 'text/csv', + displayName: 'source.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000022', + tenantScope: scope, + kind: dataMode === 'Cloud' ? 'CLOUD' : 'LOCAL', + opaqueReference: 'source-reference_1234', + contentSha256: 'a'.repeat(64), + }, + } as const; +} + +void test('[IAE-007, IAE-008] derivative registration resolves sources and persists lineage atomically', async () => { + const artifacts = new InMemoryArtifactRepositoryAdapter(); + const lineage = new InMemoryArtifactLineageRepositoryAdapter(); + const service = new DerivedArtifactService(artifacts, lineage); + const source = sourceInput(); + const sourceService = new (await import('../../../src/features/iae/application/artifact.service.js')).ArtifactService(artifacts); + const registered = await sourceService.register(context('source'), source); + assert.equal(registered.accepted, true); + if (!registered.accepted) return; + const derived = await service.register(context('derived'), { + version: { + artifactId: '00000000-0000-4000-8000-000000000030', + versionId: '00000000-0000-4000-8000-000000000031', + tenantScope: scope, + sourceKind: 'GENERATED', + dataMode: 'Hybrid', + contentSha256: 'b'.repeat(64), + byteSize: 2, + mediaType: 'text/csv', + displayName: 'derived.csv', + createdAt: '2026-01-01T00:00:01.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000032', + tenantScope: scope, + kind: 'LOCAL', + opaqueReference: 'derived-reference_1234', + contentSha256: 'b'.repeat(64), + }, + sourceArtifactVersionIds: [source.version.versionId], + lineage: { + lineageId: '00000000-0000-4000-8000-000000000033', + processorVersion: 'spreadsheet-auditor@1', + coordinateLineage: [], + }, + }); + assert.equal(derived.accepted, true); + if (!derived.accepted) return; + assert.equal((await lineage.findByDerived(context('read'), derived.value.version.versionId))?.lineageId, '00000000-0000-4000-8000-000000000033'); +}); + +void test('[IAE-008] Local source cannot produce a Hybrid derivative', async () => { + const artifacts = new InMemoryArtifactRepositoryAdapter(); + const service = new DerivedArtifactService(artifacts, new InMemoryArtifactLineageRepositoryAdapter()); + const sourceService = new (await import('../../../src/features/iae/application/artifact.service.js')).ArtifactService(artifacts); + const source = sourceInput('Local'); + const registered = await sourceService.register(context('local-source'), source); + assert.equal(registered.accepted, true); + if (!registered.accepted) return; + const rejected = await service.register(context('local-derived'), { + version: { + artifactId: '00000000-0000-4000-8000-000000000040', + versionId: '00000000-0000-4000-8000-000000000041', + tenantScope: scope, + sourceKind: 'GENERATED', + dataMode: 'Hybrid', + contentSha256: 'c'.repeat(64), + byteSize: 1, + mediaType: 'text/csv', + displayName: 'leak.csv', + createdAt: '2026-01-01T00:00:01.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000042', + tenantScope: scope, + kind: 'CLOUD', + opaqueReference: 'leak-reference_1234', + contentSha256: 'c'.repeat(64), + }, + sourceArtifactVersionIds: [source.version.versionId], + lineage: { lineageId: '00000000-0000-4000-8000-000000000043', processorVersion: 'test@1', coordinateLineage: [] }, + }); + assert.deepEqual(rejected, { accepted: false, code: 'DATA_MODE_WIDENING' }); +}); + +void test('[IAE-007] missing source prevents any derivative write', async () => { + const artifacts = new InMemoryArtifactRepositoryAdapter(); + const service = new DerivedArtifactService(artifacts, new InMemoryArtifactLineageRepositoryAdapter()); + const rejected = await service.register(context('missing-source'), { + version: { + artifactId: '00000000-0000-4000-8000-000000000050', versionId: '00000000-0000-4000-8000-000000000051', + tenantScope: scope, sourceKind: 'GENERATED', dataMode: 'Local', contentSha256: 'd'.repeat(64), byteSize: 1, + mediaType: 'text/csv', displayName: 'missing.csv', createdAt: '2026-01-01T00:00:01.000Z', + }, + placement: { placementId: '00000000-0000-4000-8000-000000000052', tenantScope: scope, kind: 'LOCAL', opaqueReference: 'missing-reference_1234', contentSha256: 'd'.repeat(64) }, + sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000053'], + lineage: { lineageId: '00000000-0000-4000-8000-000000000054', processorVersion: 'test@1', coordinateLineage: [] }, + }); + assert.deepEqual(rejected, { accepted: false, code: 'SOURCE_NOT_FOUND' }); + assert.equal((await artifacts.findVersion(context('missing-read'), '00000000-0000-4000-8000-000000000051' as never)), undefined); +}); From 7f8a197b92e2016f1d9a18eda306d8b803b30e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:58:11 +0700 Subject: [PATCH 11/44] feat(dsm): add deterministic mappings and quality rule sets --- packages/domain/package.json | 8 + packages/domain/src/mapping/v1.ts | 166 ++++++++++++++++++ packages/domain/src/rule-set/v1.ts | 152 ++++++++++++++++ packages/domain/src/v1.ts | 2 + .../domain/test/built-public-api-smoke.mjs | 6 + .../domain/test/mapping-rule-set-v1.test.mjs | 58 ++++++ packages/domain/test/public-api-v1.test.mjs | 4 + 7 files changed, 396 insertions(+) create mode 100644 packages/domain/src/mapping/v1.ts create mode 100644 packages/domain/src/rule-set/v1.ts create mode 100644 packages/domain/test/mapping-rule-set-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index ad3de5d6..e83e06f8 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -95,6 +95,14 @@ "./reference-entity/v1": { "types": "./src/reference-entity/v1.ts", "import": "./dist/reference-entity/v1.js" + }, + "./mapping/v1": { + "types": "./src/mapping/v1.ts", + "import": "./dist/mapping/v1.js" + }, + "./rule-set/v1": { + "types": "./src/rule-set/v1.ts", + "import": "./dist/rule-set/v1.js" } }, "scripts": { diff --git a/packages/domain/src/mapping/v1.ts b/packages/domain/src/mapping/v1.ts new file mode 100644 index 00000000..efad6581 --- /dev/null +++ b/packages/domain/src/mapping/v1.ts @@ -0,0 +1,166 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-007, DSM-008: deterministic, declarative field mappings. */ +export const MAPPING_SCHEMA_VERSION_V1 = 1 as const; + +export type MappingStatusV1 = 'DRAFT' | 'PUBLISHED' | 'RETIRED'; +export type MappingTransformV1 = + | 'IDENTITY' + | 'TRIM' + | 'LOWERCASE' + | 'UPPERCASE' + | 'PARSE_DECIMAL' + | 'PARSE_DATE' + | 'LOOKUP'; + +export interface MappingStepV1 { + readonly sourceFieldId: StableIdentifierV1; + readonly targetFieldId: StableIdentifierV1; + readonly transform: MappingTransformV1; + readonly lookupVersionId?: StableIdentifierV1; +} + +export interface MappingDefinitionV1 { + readonly schemaVersion: typeof MAPPING_SCHEMA_VERSION_V1; + readonly datasetId: StableIdentifierV1; + readonly versionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly sourceSchemaVersionId: StableIdentifierV1; + readonly targetSchemaVersionId: StableIdentifierV1; + readonly steps: readonly MappingStepV1[]; + readonly status: MappingStatusV1; + readonly createdAt: StrictUtcTimestampV1; + readonly publishedAt?: StrictUtcTimestampV1; + readonly canonicalHash: string; +} + +export type MappingErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_STATE' + | 'INVALID_STEP' + | 'DUPLICATE_MAPPING' + | 'LOOKUP_REQUIRED'; + +export type MappingResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: MappingErrorCodeV1 }; + +function accepted(value: TValue): MappingResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: MappingErrorCodeV1): MappingResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const result = parseTenantScopeV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) ? input.toLowerCase() : undefined; +} + +function mappingStep(input: unknown): MappingStepV1 | MappingErrorCodeV1 { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return 'INVALID_STEP'; + const record = input as Record; + const sourceFieldId = identifier(record['sourceFieldId']); + const targetFieldId = identifier(record['targetFieldId']); + const transform = record['transform']; + const lookupVersionId = record['lookupVersionId'] === undefined ? undefined : identifier(record['lookupVersionId']); + if (!sourceFieldId || !targetFieldId || !['IDENTITY', 'TRIM', 'LOWERCASE', 'UPPERCASE', 'PARSE_DECIMAL', 'PARSE_DATE', 'LOOKUP'].includes(transform as string)) + return 'INVALID_STEP'; + if (record['lookupVersionId'] !== undefined && !lookupVersionId) return 'INVALID_IDENTIFIER'; + if (transform === 'LOOKUP' && !lookupVersionId) return 'LOOKUP_REQUIRED'; + return Object.freeze({ + sourceFieldId, + targetFieldId, + transform: transform as MappingTransformV1, + ...(lookupVersionId ? { lookupVersionId } : {}), + }); +} + +export function createMappingDefinitionV1(input: { + readonly datasetId: unknown; + readonly versionId: unknown; + readonly tenantScope: unknown; + readonly sourceSchemaVersionId: unknown; + readonly targetSchemaVersionId: unknown; + readonly steps: unknown; + readonly status?: unknown; + readonly createdAt: unknown; + readonly publishedAt?: unknown; + readonly canonicalHash: unknown; +}): MappingResultV1 { + const datasetId = identifier(input.datasetId); + const versionId = identifier(input.versionId); + const tenantScope = scope(input.tenantScope); + const sourceSchemaVersionId = identifier(input.sourceSchemaVersionId); + const targetSchemaVersionId = identifier(input.targetSchemaVersionId); + const createdAt = timestamp(input.createdAt); + const publishedAt = input.publishedAt === undefined ? undefined : timestamp(input.publishedAt); + const canonicalHash = hash(input.canonicalHash); + if (!datasetId || !versionId || !sourceSchemaVersionId || !targetSchemaVersionId) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) return rejected('INVALID_TIMESTAMP'); + if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) return rejected('INVALID_TIMESTAMP'); + if (!canonicalHash) return rejected('INVALID_HASH'); + if (!Array.isArray(input.steps) || input.steps.length === 0 || input.steps.length > 512) + return rejected('INVALID_STEP'); + const parsedSteps = input.steps.map(mappingStep); + if (parsedSteps.some((step): step is MappingErrorCodeV1 => typeof step === 'string')) + return rejected(parsedSteps.find((step): step is MappingErrorCodeV1 => typeof step === 'string') ?? 'INVALID_STEP'); + const steps = parsedSteps as MappingStepV1[]; + const targets = new Set(steps.map((step) => step.targetFieldId)); + if (targets.size !== steps.length) return rejected('DUPLICATE_MAPPING'); + const status = input.status ?? 'DRAFT'; + if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) return rejected('INVALID_STATE'); + return accepted(Object.freeze({ + schemaVersion: MAPPING_SCHEMA_VERSION_V1, + datasetId, + versionId, + tenantScope, + sourceSchemaVersionId, + targetSchemaVersionId, + steps: Object.freeze(steps), + status: status as MappingStatusV1, + createdAt, + ...(publishedAt ? { publishedAt } : {}), + canonicalHash, + })); +} + +export function publishMappingDefinitionV1( + definition: MappingDefinitionV1, + nextVersionIdInput: unknown, + publishedAtInput: unknown, +): MappingResultV1 { + const nextVersionId = identifier(nextVersionIdInput); + const publishedAt = timestamp(publishedAtInput); + if (!nextVersionId) return rejected('INVALID_IDENTIFIER'); + if (!publishedAt || Date.parse(publishedAt) < Date.parse(definition.createdAt)) return rejected('INVALID_TIMESTAMP'); + if (definition.status !== 'DRAFT') return rejected('INVALID_STATE'); + return accepted(Object.freeze({ ...definition, versionId: nextVersionId, status: 'PUBLISHED' as const, publishedAt })); +} diff --git a/packages/domain/src/rule-set/v1.ts b/packages/domain/src/rule-set/v1.ts new file mode 100644 index 00000000..521bd902 --- /dev/null +++ b/packages/domain/src/rule-set/v1.ts @@ -0,0 +1,152 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-009, DSM-010, DSM-011, DSM-015: bounded declarative quality rules. */ +export const RULE_SET_SCHEMA_VERSION_V1 = 1 as const; + +export type RuleSetStatusV1 = 'DRAFT' | 'PUBLISHED' | 'RETIRED'; +export type RuleSeverityV1 = 'ERROR' | 'WARNING'; +export type RuleKindV1 = 'REQUIRED' | 'TYPE' | 'RANGE' | 'UNIQUE' | 'REFERENCE'; +export type RuleTypeV1 = 'TEXT' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'DATE'; + +export type RuleParametersV1 = + | Readonly> + | { readonly expectedType: RuleTypeV1 } + | { readonly minimum?: number; readonly maximum?: number } + | { readonly referenceEntityVersionId: StableIdentifierV1 }; + +export interface QualityRuleV1 { + readonly ruleId: StableIdentifierV1; + readonly fieldId: StableIdentifierV1; + readonly kind: RuleKindV1; + readonly severity: RuleSeverityV1; + readonly parameters: RuleParametersV1; +} + +export interface RuleSetDefinitionV1 { + readonly schemaVersion: typeof RULE_SET_SCHEMA_VERSION_V1; + readonly datasetId: StableIdentifierV1; + readonly versionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly schemaVersionId: StableIdentifierV1; + readonly rules: readonly QualityRuleV1[]; + readonly status: RuleSetStatusV1; + readonly createdAt: StrictUtcTimestampV1; + readonly publishedAt?: StrictUtcTimestampV1; + readonly canonicalHash: string; +} + +export type RuleSetErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_STATE' + | 'INVALID_RULE' + | 'DUPLICATE_RULE' + | 'INVALID_PARAMETERS'; + +export type RuleSetResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: RuleSetErrorCodeV1 }; + +function accepted(value: TValue): RuleSetResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: RuleSetErrorCodeV1): RuleSetResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const result = parseTenantScopeV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) ? input.toLowerCase() : undefined; +} + +function rule(input: unknown): QualityRuleV1 | RuleSetErrorCodeV1 { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return 'INVALID_RULE'; + const record = input as Record; + const ruleId = identifier(record['ruleId']); + const fieldId = identifier(record['fieldId']); + const kind = record['kind']; + const severity = record['severity']; + const parameters = record['parameters'] ?? {}; + if (!ruleId || !fieldId || !['REQUIRED', 'TYPE', 'RANGE', 'UNIQUE', 'REFERENCE'].includes(kind as string)) return 'INVALID_RULE'; + if (!['ERROR', 'WARNING'].includes(severity as string)) return 'INVALID_RULE'; + if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) return 'INVALID_PARAMETERS'; + if (kind === 'TYPE') { + if (!['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes((parameters as Record)['expectedType'] as string)) return 'INVALID_PARAMETERS'; + } else if (kind === 'RANGE') { + const range = parameters as Record; + const minimum = range['minimum']; + const maximum = range['maximum']; + if ((minimum !== undefined && (typeof minimum !== 'number' || !Number.isFinite(minimum))) || (maximum !== undefined && (typeof maximum !== 'number' || !Number.isFinite(maximum))) || (minimum === undefined && maximum === undefined) || (minimum !== undefined && maximum !== undefined && minimum > maximum)) return 'INVALID_PARAMETERS'; + } else if (kind === 'REFERENCE') { + if (!identifier((parameters as Record)['referenceEntityVersionId'])) return 'INVALID_PARAMETERS'; + } else if (Object.keys(parameters as object).length > 0) { + return 'INVALID_PARAMETERS'; + } + return Object.freeze({ ruleId, fieldId, kind: kind as RuleKindV1, severity: severity as RuleSeverityV1, parameters: Object.freeze({ ...(parameters as Record) }) as RuleParametersV1 }); +} + +export function createRuleSetDefinitionV1(input: { + readonly datasetId: unknown; + readonly versionId: unknown; + readonly tenantScope: unknown; + readonly schemaVersionId: unknown; + readonly rules: unknown; + readonly status?: unknown; + readonly createdAt: unknown; + readonly publishedAt?: unknown; + readonly canonicalHash: unknown; +}): RuleSetResultV1 { + const datasetId = identifier(input.datasetId); + const versionId = identifier(input.versionId); + const tenantScope = scope(input.tenantScope); + const schemaVersionId = identifier(input.schemaVersionId); + const createdAt = timestamp(input.createdAt); + const publishedAt = input.publishedAt === undefined ? undefined : timestamp(input.publishedAt); + const canonicalHash = hash(input.canonicalHash); + if (!datasetId || !versionId || !schemaVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) return rejected('INVALID_TIMESTAMP'); + if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) return rejected('INVALID_TIMESTAMP'); + if (!canonicalHash) return rejected('INVALID_HASH'); + if (!Array.isArray(input.rules) || input.rules.length === 0 || input.rules.length > 512) return rejected('INVALID_RULE'); + const parsedRules = input.rules.map(rule); + if (parsedRules.some((candidate): candidate is RuleSetErrorCodeV1 => typeof candidate === 'string')) return rejected(parsedRules.find((candidate): candidate is RuleSetErrorCodeV1 => typeof candidate === 'string') ?? 'INVALID_RULE'); + const rules = parsedRules as QualityRuleV1[]; + if (new Set(rules.map((candidate) => candidate.ruleId)).size !== rules.length) return rejected('DUPLICATE_RULE'); + const status = input.status ?? 'DRAFT'; + if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) return rejected('INVALID_STATE'); + return accepted(Object.freeze({ schemaVersion: RULE_SET_SCHEMA_VERSION_V1, datasetId, versionId, tenantScope, schemaVersionId, rules: Object.freeze(rules), status: status as RuleSetStatusV1, createdAt, ...(publishedAt ? { publishedAt } : {}), canonicalHash })); +} + +export function publishRuleSetDefinitionV1(definition: RuleSetDefinitionV1, nextVersionIdInput: unknown, publishedAtInput: unknown): RuleSetResultV1 { + const nextVersionId = identifier(nextVersionIdInput); + const publishedAt = timestamp(publishedAtInput); + if (!nextVersionId) return rejected('INVALID_IDENTIFIER'); + if (!publishedAt || Date.parse(publishedAt) < Date.parse(definition.createdAt)) return rejected('INVALID_TIMESTAMP'); + if (definition.status !== 'DRAFT') return rejected('INVALID_STATE'); + return accepted(Object.freeze({ ...definition, versionId: nextVersionId, status: 'PUBLISHED' as const, publishedAt })); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 0e725ec8..db052b26 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -13,6 +13,8 @@ export * from './dispatch/v1.js'; export * from './recipe/v1.js'; export * from './finding/v1.js'; export * from './reference-entity/v1.js'; +export * from './mapping/v1.js'; +export * from './rule-set/v1.js'; export * from './identity/v1.js'; export * from './entitlements/v1.js'; export * from './mfa/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 1f5ac572..fb1618ad 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -19,6 +19,8 @@ const [ recipe, finding, referenceEntity, + mapping, + ruleSet, ] = await Promise.all([ import('@databreeze/domain/v1'), import('@databreeze/domain/permissions/v1'), @@ -38,6 +40,8 @@ const [ import('@databreeze/domain/recipe/v1'), import('@databreeze/domain/finding/v1'), import('@databreeze/domain/reference-entity/v1'), + import('@databreeze/domain/mapping/v1'), + import('@databreeze/domain/rule-set/v1'), ]); assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); @@ -59,4 +63,6 @@ assert.equal(dispatch.DISPATCH_SCHEMA_VERSION_V1, 1); assert.equal(recipe.RECIPE_SCHEMA_VERSION_V1, 1); assert.equal(finding.FINDING_SCHEMA_VERSION_V1, 1); assert.equal(referenceEntity.REFERENCE_ENTITY_SCHEMA_VERSION_V1, 1); + assert.equal(mapping.MAPPING_SCHEMA_VERSION_V1, 1); + assert.equal(ruleSet.RULE_SET_SCHEMA_VERSION_V1, 1); await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/mapping-rule-set-v1.test.mjs b/packages/domain/test/mapping-rule-set-v1.test.mjs new file mode 100644 index 00000000..63fd7cfc --- /dev/null +++ b/packages/domain/test/mapping-rule-set-v1.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createMappingDefinitionV1, publishMappingDefinitionV1 } from '../dist/mapping/v1.js'; +import { createRuleSetDefinitionV1, publishRuleSetDefinitionV1 } from '../dist/rule-set/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; + +void test('[DSM-007, DSM-008] mappings are bounded, declarative, and publish as immutable versions', () => { + const input = { + datasetId: '00000000-0000-4000-8000-000000000010', + versionId: '00000000-0000-4000-8000-000000000011', + tenantScope: scope, + sourceSchemaVersionId: '00000000-0000-4000-8000-000000000012', + targetSchemaVersionId: '00000000-0000-4000-8000-000000000013', + steps: [{ sourceFieldId: '00000000-0000-4000-8000-000000000014', targetFieldId: '00000000-0000-4000-8000-000000000015', transform: 'LOOKUP', lookupVersionId: '00000000-0000-4000-8000-000000000016' }], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + }; + const created = createMappingDefinitionV1(input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const published = publishMappingDefinitionV1(created.value, '00000000-0000-4000-8000-000000000017', '2026-01-01T00:01:00.000Z'); + assert.equal(published.accepted, true); + assert.deepEqual(createMappingDefinitionV1({ ...input, steps: [{ ...input.steps[0], transform: 'LOOKUP' }] }), created); +}); + +void test('[DSM-007] mappings reject duplicate targets and executable transforms', () => { + const base = { + datasetId: '00000000-0000-4000-8000-000000000020', versionId: '00000000-0000-4000-8000-000000000021', tenantScope: scope, + sourceSchemaVersionId: '00000000-0000-4000-8000-000000000022', targetSchemaVersionId: '00000000-0000-4000-8000-000000000023', createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'b'.repeat(64), + }; + assert.deepEqual(createMappingDefinitionV1({ ...base, steps: [ + { sourceFieldId: '00000000-0000-4000-8000-000000000024', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'IDENTITY' }, + { sourceFieldId: '00000000-0000-4000-8000-000000000026', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'IDENTITY' }, + ] }), { accepted: false, code: 'DUPLICATE_MAPPING' }); + assert.deepEqual(createMappingDefinitionV1({ ...base, steps: [{ sourceFieldId: '00000000-0000-4000-8000-000000000024', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'EXECUTE_SCRIPT' }] }), { accepted: false, code: 'INVALID_STEP' }); +}); + +void test('[DSM-009, DSM-010, DSM-011] rule sets accept only typed deterministic parameters', () => { + const input = { + datasetId: '00000000-0000-4000-8000-000000000030', versionId: '00000000-0000-4000-8000-000000000031', tenantScope: scope, + schemaVersionId: '00000000-0000-4000-8000-000000000032', createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'c'.repeat(64), + rules: [ + { ruleId: '00000000-0000-4000-8000-000000000033', fieldId: '00000000-0000-4000-8000-000000000034', kind: 'REQUIRED', severity: 'ERROR' }, + { ruleId: '00000000-0000-4000-8000-000000000035', fieldId: '00000000-0000-4000-8000-000000000036', kind: 'RANGE', severity: 'WARNING', parameters: { minimum: 0, maximum: 100 } }, + ], + }; + const created = createRuleSetDefinitionV1(input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(publishRuleSetDefinitionV1(created.value, '00000000-0000-4000-8000-000000000037', '2026-01-01T00:01:00.000Z').accepted, true); + assert.deepEqual(createRuleSetDefinitionV1({ ...input, rules: [{ ...input.rules[0], parameters: { script: 'drop table' } }] }), { accepted: false, code: 'INVALID_PARAMETERS' }); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index e492c059..9fc763ff 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -32,6 +32,8 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './recipe/v1', './finding/v1', './reference-entity/v1', + './mapping/v1', + './rule-set/v1', ]); for (const entry of Object.values(manifest.exports)) { @@ -56,6 +58,8 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); + assert.equal(aggregate.MAPPING_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.RULE_SET_SCHEMA_VERSION_V1, 1); }); test('[IAM-004] does not expose an unversioned package root', async () => { From b8b5ab72d888074fe5e276e7c5d038830f41b801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 10:59:49 +0700 Subject: [PATCH 12/44] feat(dsm): add scoped mapping definition service --- .../in-memory-mapping-repository.adapter.ts | 69 +++++++++++++++++++ .../application/mapping-repository.port.ts | 16 +++++ .../dsm/application/mapping.service.ts | 46 +++++++++++++ .../test/features/dsm/mapping.service.test.ts | 50 ++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/mapping-repository.port.ts create mode 100644 services/api/src/features/dsm/application/mapping.service.ts create mode 100644 services/api/test/features/dsm/mapping.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts new file mode 100644 index 00000000..62cd30cb --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts @@ -0,0 +1,69 @@ +import { + tenantScopeContainsV1, + type MappingDefinitionV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + MappingRepositoryPortV1, + MappingTransactionPortV1, +} from '../application/mapping-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(definition: MappingDefinitionV1): MappingDefinitionV1 { + return Object.freeze({ + ...definition, + tenantScope: Object.freeze({ ...definition.tenantScope }), + steps: Object.freeze(definition.steps.map((step) => Object.freeze({ ...step }))), + }); +} + +export class InMemoryMappingRepositoryAdapter implements MappingRepositoryPortV1 { + private definitions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, definition: MappingDefinitionV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.definitions.get(definition.versionId); + if (existing && JSON.stringify(existing) !== JSON.stringify(definition)) + throw new Error('DSM_IMMUTABLE_MAPPING'); + this.definitions.set(definition.versionId, clone(definition)); + } + + public async find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise { + await Promise.resolve(); + const definition = this.definitions.get(versionId); + return definition && visible(context.tenantScope, definition.tenantScope) ? clone(definition) : undefined; + } + + public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { + await Promise.resolve(); + return [...this.definitions.values()] + .filter((definition) => definition.datasetId === datasetId && visible(context.tenantScope, definition.tenantScope)) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .map(clone); + } + + public async withTransaction(context: IamTenantContextV1, work: (transaction: MappingTransactionPortV1) => Promise): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { release = resolve; }); + await previous; + const before = new Map(this.definitions); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this), list: this.list.bind(this) }); + } catch (error) { + this.definitions = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/mapping-repository.port.ts b/services/api/src/features/dsm/application/mapping-repository.port.ts new file mode 100644 index 00000000..1a745fa6 --- /dev/null +++ b/services/api/src/features/dsm/application/mapping-repository.port.ts @@ -0,0 +1,16 @@ +import type { MappingDefinitionV1 } from '@databreeze/domain/mapping/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const MAPPING_REPOSITORY_PORT = Symbol('MAPPING_REPOSITORY_PORT'); + +export interface MappingTransactionPortV1 { + save(context: IamTenantContextV1, definition: MappingDefinitionV1): Promise; + find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise; + list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise; +} + +export interface MappingRepositoryPortV1 extends MappingTransactionPortV1 { + withTransaction(context: IamTenantContextV1, work: (transaction: MappingTransactionPortV1) => Promise): Promise; +} diff --git a/services/api/src/features/dsm/application/mapping.service.ts b/services/api/src/features/dsm/application/mapping.service.ts new file mode 100644 index 00000000..60e91021 --- /dev/null +++ b/services/api/src/features/dsm/application/mapping.service.ts @@ -0,0 +1,46 @@ +import { + createMappingDefinitionV1, + publishMappingDefinitionV1, + type MappingDefinitionV1, + type MappingResultV1, +} from '@databreeze/domain/mapping/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { MappingRepositoryPortV1 } from './mapping-repository.port.js'; + +export type MappingServiceErrorV1 = 'VERSION_NOT_FOUND'; +export type MappingServiceResultV1 = MappingResultV1 | { readonly accepted: false; readonly code: MappingServiceErrorV1 }; + +export class MappingService { + public constructor(private readonly repository: MappingRepositoryPortV1) {} + + public async create(context: IamTenantContextV1, input: Parameters[0]): Promise> { + const created = createMappingDefinitionV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.versionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) return created; + throw new Error('DSM_IMMUTABLE_MAPPING'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async publish(context: IamTenantContextV1, versionId: StableIdentifierV1, nextVersionIdInput: unknown, publishedAt: unknown): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, versionId); + if (!current) return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + const published = publishMappingDefinitionV1(current, nextVersionIdInput, publishedAt); + if (!published.accepted) return published; + await transaction.save(context, published.value); + return published; + }); + } + + public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { + return this.repository.withTransaction(context, (transaction) => transaction.list(context, datasetId)); + } +} diff --git a/services/api/test/features/dsm/mapping.service.test.ts b/services/api/test/features/dsm/mapping.service.test.ts new file mode 100644 index 00000000..d901f97c --- /dev/null +++ b/services/api/test/features/dsm/mapping.service.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryMappingRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-mapping-repository.adapter.js'; +import { MappingService } from '../../../src/features/dsm/application/mapping.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000003'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; + +function context(workspaceIdValue: string, idempotencyKey: string) { + const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspaceIdValue }, actorId, correlationId, idempotencyKey, authorizationEpoch: 1 }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid id'); + return result.value; +} + +const input = { + datasetId: '00000000-0000-4000-8000-000000000020', versionId: '00000000-0000-4000-8000-000000000021', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, sourceSchemaVersionId: '00000000-0000-4000-8000-000000000022', targetSchemaVersionId: '00000000-0000-4000-8000-000000000023', + steps: [{ sourceFieldId: '00000000-0000-4000-8000-000000000024', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'TRIM' }], createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'a'.repeat(64), +}; + +void test('[DSM-007, DSM-008] mapping service versions and publishes definitions', async () => { + const service = new MappingService(new InMemoryMappingRepositoryAdapter()); + const created = await service.create(context(workspaceId, 'mapping-create'), input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const published = await service.publish(context(workspaceId, 'mapping-publish'), stable(input.versionId), '00000000-0000-4000-8000-000000000026', '2026-01-01T00:01:00.000Z'); + assert.equal(published.accepted, true); + assert.equal((await service.list(context(workspaceId, 'mapping-list'), stable(input.datasetId))).length, 2); +}); + +void test('[IAM-009, DSM-007] sibling workspaces cannot read mappings', async () => { + const service = new MappingService(new InMemoryMappingRepositoryAdapter()); + await service.create(context(workspaceId, 'mapping-scope-create'), input); + assert.equal((await service.list(context(siblingWorkspaceId, 'mapping-scope-list'), stable(input.datasetId))).length, 0); +}); From e25aac38fc79ef4931b0c2c3078340ad7b36cf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:01:12 +0700 Subject: [PATCH 13/44] feat(dsm): add scoped quality rule-set service --- .../in-memory-rule-set-repository.adapter.ts | 67 +++++++++++++++++++ .../application/rule-set-repository.port.ts | 16 +++++ .../dsm/application/rule-set.service.ts | 46 +++++++++++++ .../features/dsm/rule-set.service.test.ts | 47 +++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/rule-set-repository.port.ts create mode 100644 services/api/src/features/dsm/application/rule-set.service.ts create mode 100644 services/api/test/features/dsm/rule-set.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts new file mode 100644 index 00000000..e4b4d5a6 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts @@ -0,0 +1,67 @@ +import { + tenantScopeContainsV1, + type RuleSetDefinitionV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + RuleSetRepositoryPortV1, + RuleSetTransactionPortV1, +} from '../application/rule-set-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(definition: RuleSetDefinitionV1): RuleSetDefinitionV1 { + return Object.freeze({ + ...definition, + tenantScope: Object.freeze({ ...definition.tenantScope }), + rules: Object.freeze(definition.rules.map((rule) => Object.freeze({ ...rule, parameters: Object.freeze({ ...rule.parameters }) }))), + }); +} + +export class InMemoryRuleSetRepositoryAdapter implements RuleSetRepositoryPortV1 { + private definitions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, definition: RuleSetDefinitionV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.definitions.get(definition.versionId); + if (existing && JSON.stringify(existing) !== JSON.stringify(definition)) throw new Error('DSM_IMMUTABLE_RULE_SET'); + this.definitions.set(definition.versionId, clone(definition)); + } + + public async find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise { + await Promise.resolve(); + const definition = this.definitions.get(versionId); + return definition && visible(context.tenantScope, definition.tenantScope) ? clone(definition) : undefined; + } + + public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { + await Promise.resolve(); + return [...this.definitions.values()] + .filter((definition) => definition.datasetId === datasetId && visible(context.tenantScope, definition.tenantScope)) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .map(clone); + } + + public async withTransaction(context: IamTenantContextV1, work: (transaction: RuleSetTransactionPortV1) => Promise): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { release = resolve; }); + await previous; + const before = new Map(this.definitions); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this), list: this.list.bind(this) }); + } catch (error) { + this.definitions = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/rule-set-repository.port.ts b/services/api/src/features/dsm/application/rule-set-repository.port.ts new file mode 100644 index 00000000..a020a25d --- /dev/null +++ b/services/api/src/features/dsm/application/rule-set-repository.port.ts @@ -0,0 +1,16 @@ +import type { RuleSetDefinitionV1 } from '@databreeze/domain/rule-set/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const RULE_SET_REPOSITORY_PORT = Symbol('RULE_SET_REPOSITORY_PORT'); + +export interface RuleSetTransactionPortV1 { + save(context: IamTenantContextV1, definition: RuleSetDefinitionV1): Promise; + find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise; + list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise; +} + +export interface RuleSetRepositoryPortV1 extends RuleSetTransactionPortV1 { + withTransaction(context: IamTenantContextV1, work: (transaction: RuleSetTransactionPortV1) => Promise): Promise; +} diff --git a/services/api/src/features/dsm/application/rule-set.service.ts b/services/api/src/features/dsm/application/rule-set.service.ts new file mode 100644 index 00000000..3f543554 --- /dev/null +++ b/services/api/src/features/dsm/application/rule-set.service.ts @@ -0,0 +1,46 @@ +import { + createRuleSetDefinitionV1, + publishRuleSetDefinitionV1, + type RuleSetDefinitionV1, + type RuleSetResultV1, +} from '@databreeze/domain/rule-set/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { RuleSetRepositoryPortV1 } from './rule-set-repository.port.js'; + +export type RuleSetServiceErrorV1 = 'VERSION_NOT_FOUND'; +export type RuleSetServiceResultV1 = RuleSetResultV1 | { readonly accepted: false; readonly code: RuleSetServiceErrorV1 }; + +export class RuleSetService { + public constructor(private readonly repository: RuleSetRepositoryPortV1) {} + + public async create(context: IamTenantContextV1, input: Parameters[0]): Promise> { + const created = createRuleSetDefinitionV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.versionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) return created; + throw new Error('DSM_IMMUTABLE_RULE_SET'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async publish(context: IamTenantContextV1, versionId: StableIdentifierV1, nextVersionIdInput: unknown, publishedAt: unknown): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, versionId); + if (!current) return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + const published = publishRuleSetDefinitionV1(current, nextVersionIdInput, publishedAt); + if (!published.accepted) return published; + await transaction.save(context, published.value); + return published; + }); + } + + public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { + return this.repository.withTransaction(context, (transaction) => transaction.list(context, datasetId)); + } +} diff --git a/services/api/test/features/dsm/rule-set.service.test.ts b/services/api/test/features/dsm/rule-set.service.test.ts new file mode 100644 index 00000000..bef9ed90 --- /dev/null +++ b/services/api/test/features/dsm/rule-set.service.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryRuleSetRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-rule-set-repository.adapter.js'; +import { RuleSetService } from '../../../src/features/dsm/application/rule-set.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; + +function context(idempotencyKey: string) { + const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, actorId, correlationId, idempotencyKey, authorizationEpoch: 1 }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid id'); + return result.value; +} + +const input = { + datasetId: '00000000-0000-4000-8000-000000000020', versionId: '00000000-0000-4000-8000-000000000021', tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, schemaVersionId: '00000000-0000-4000-8000-000000000022', createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'a'.repeat(64), + rules: [{ ruleId: '00000000-0000-4000-8000-000000000023', fieldId: '00000000-0000-4000-8000-000000000024', kind: 'REQUIRED', severity: 'ERROR' }], +}; + +void test('[DSM-009, DSM-010, DSM-011] rule-set service versions and publishes deterministic rules', async () => { + const service = new RuleSetService(new InMemoryRuleSetRepositoryAdapter()); + const created = await service.create(context('rules-create'), input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal((await service.publish(context('rules-publish'), stable(input.versionId), '00000000-0000-4000-8000-000000000025', '2026-01-01T00:01:00.000Z')).accepted, true); + assert.equal((await service.list(context('rules-list'), stable(input.datasetId))).length, 2); +}); + +void test('[DSM-009] missing rule-set versions return a stable application error', async () => { + const service = new RuleSetService(new InMemoryRuleSetRepositoryAdapter()); + const result = await service.publish(context('rules-missing'), stable('00000000-0000-4000-8000-000000000026'), '00000000-0000-4000-8000-000000000027', '2026-01-01T00:01:00.000Z'); + assert.deepEqual(result, { accepted: false, code: 'VERSION_NOT_FOUND' }); +}); From 1f4377d380175c448780e4394ef3d2ffae794c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:03:18 +0700 Subject: [PATCH 14/44] feat(dsm): add immutable reference entity resolutions --- ...ory-reference-entity-repository.adapter.ts | 103 ++++++++++++++++++ .../reference-entity-repository.port.ts | 19 ++++ .../application/reference-entity.service.ts | 65 +++++++++++ .../dsm/reference-entity.service.test.ts | 56 ++++++++++ 4 files changed, 243 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/reference-entity-repository.port.ts create mode 100644 services/api/src/features/dsm/application/reference-entity.service.ts create mode 100644 services/api/test/features/dsm/reference-entity.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts new file mode 100644 index 00000000..58dd76e4 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts @@ -0,0 +1,103 @@ +import { + tenantScopeContainsV1, + type BusinessPartyResolutionV1, + type BusinessPartyVersionV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ReferenceEntityRepositoryPortV1, + ReferenceEntityTransactionPortV1, +} from '../application/reference-entity-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function cloneVersion(version: BusinessPartyVersionV1): BusinessPartyVersionV1 { + return Object.freeze({ + ...version, + tenantScope: Object.freeze({ ...version.tenantScope }), + roles: Object.freeze([...version.roles]), + aliases: Object.freeze([...version.aliases]), + externalIdentifiers: Object.freeze(version.externalIdentifiers.map((item) => Object.freeze({ ...item }))), + }); +} + +function cloneResolution(resolution: BusinessPartyResolutionV1): BusinessPartyResolutionV1 { + return Object.freeze({ ...resolution }); +} + +export class InMemoryReferenceEntityRepositoryAdapter implements ReferenceEntityRepositoryPortV1 { + private versions = new Map(); + private resolutions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async saveVersion(context: IamTenantContextV1, version: BusinessPartyVersionV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.versions.get(version.versionId); + if (existing && JSON.stringify(existing) !== JSON.stringify(version)) throw new Error('DSM_IMMUTABLE_REFERENCE_VERSION'); + this.versions.set(version.versionId, cloneVersion(version)); + } + + public async findVersion(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise { + await Promise.resolve(); + const version = this.versions.get(versionId); + return version && visible(context.tenantScope, version.tenantScope) ? cloneVersion(version) : undefined; + } + + public async findLatest(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + const versions = await this.listVersions(context, entityId); + return versions.at(-1); + } + + public async listVersions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + await Promise.resolve(); + return [...this.versions.values()] + .filter((version) => version.entityId === entityId && visible(context.tenantScope, version.tenantScope)) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .map(cloneVersion); + } + + public async saveResolution(context: IamTenantContextV1, resolution: BusinessPartyResolutionV1): Promise { + await Promise.resolve(); + const source = [...this.versions.values()].find((candidate) => candidate.entityId === resolution.sourceEntityId); + const target = [...this.versions.values()].find((candidate) => candidate.entityId === resolution.targetEntityId); + if (!source || !target || !visible(context.tenantScope, source.tenantScope) || !visible(context.tenantScope, target.tenantScope)) throw new Error('DSM_REFERENCE_ENTITY_NOT_FOUND'); + const existing = this.resolutions.get(resolution.resolutionId); + if (existing && JSON.stringify(existing) !== JSON.stringify(resolution)) throw new Error('DSM_IMMUTABLE_REFERENCE_RESOLUTION'); + this.resolutions.set(resolution.resolutionId, cloneResolution(resolution)); + } + + public async listResolutions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + await Promise.resolve(); + return [...this.resolutions.values()] + .filter((resolution) => { + const source = [...this.versions.values()].find((candidate) => candidate.entityId === resolution.sourceEntityId); + return (resolution.sourceEntityId === entityId || resolution.targetEntityId === entityId) && source !== undefined && visible(context.tenantScope, source.tenantScope); + }) + .sort((left, right) => left.resolvedAt.localeCompare(right.resolvedAt)) + .map(cloneResolution); + } + + public async withTransaction(context: IamTenantContextV1, work: (transaction: ReferenceEntityTransactionPortV1) => Promise): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { release = resolve; }); + await previous; + const beforeVersions = new Map(this.versions); + const beforeResolutions = new Map(this.resolutions); + try { + return await work({ saveVersion: this.saveVersion.bind(this), findVersion: this.findVersion.bind(this), findLatest: this.findLatest.bind(this), listVersions: this.listVersions.bind(this), saveResolution: this.saveResolution.bind(this), listResolutions: this.listResolutions.bind(this) }); + } catch (error) { + this.versions = beforeVersions; + this.resolutions = beforeResolutions; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/reference-entity-repository.port.ts b/services/api/src/features/dsm/application/reference-entity-repository.port.ts new file mode 100644 index 00000000..a464cb2c --- /dev/null +++ b/services/api/src/features/dsm/application/reference-entity-repository.port.ts @@ -0,0 +1,19 @@ +import type { BusinessPartyResolutionV1, BusinessPartyVersionV1 } from '@databreeze/domain/reference-entity/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const REFERENCE_ENTITY_REPOSITORY_PORT = Symbol('REFERENCE_ENTITY_REPOSITORY_PORT'); + +export interface ReferenceEntityTransactionPortV1 { + saveVersion(context: IamTenantContextV1, version: BusinessPartyVersionV1): Promise; + findVersion(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise; + findLatest(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise; + listVersions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise; + saveResolution(context: IamTenantContextV1, resolution: BusinessPartyResolutionV1): Promise; + listResolutions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise; +} + +export interface ReferenceEntityRepositoryPortV1 extends ReferenceEntityTransactionPortV1 { + withTransaction(context: IamTenantContextV1, work: (transaction: ReferenceEntityTransactionPortV1) => Promise): Promise; +} diff --git a/services/api/src/features/dsm/application/reference-entity.service.ts b/services/api/src/features/dsm/application/reference-entity.service.ts new file mode 100644 index 00000000..0079875e --- /dev/null +++ b/services/api/src/features/dsm/application/reference-entity.service.ts @@ -0,0 +1,65 @@ +import { + createBusinessPartyVersionV1, + mergeBusinessPartyVersionsV1, + type BusinessPartyResolutionV1, + type BusinessPartyVersionV1, + type ReferenceEntityResultV1, +} from '@databreeze/domain/reference-entity/v1'; +import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ReferenceEntityRepositoryPortV1 } from './reference-entity-repository.port.js'; + +export type ReferenceEntityServiceErrorV1 = 'ENTITY_NOT_FOUND' | 'ACTOR_MISMATCH'; +export type ReferenceEntityServiceResultV1 = ReferenceEntityResultV1 | { readonly accepted: false; readonly code: ReferenceEntityServiceErrorV1 }; + +export class ReferenceEntityService { + public constructor(private readonly repository: ReferenceEntityRepositoryPortV1) {} + + public async create(context: IamTenantContextV1, input: Parameters[0]): Promise> { + const created = createBusinessPartyVersionV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.findVersion(context, created.value.versionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) return created; + throw new Error('DSM_IMMUTABLE_REFERENCE_VERSION'); + } + await transaction.saveVersion(context, created.value); + return created; + }); + } + + public async merge(context: IamTenantContextV1, input: { + readonly sourceEntityId: unknown; + readonly targetEntityId: unknown; + readonly resolutionId: unknown; + readonly actorId: unknown; + readonly reason: unknown; + readonly evidenceId: unknown; + readonly resolvedAt: unknown; + }): Promise> { + const sourceEntityId = parseStableIdentifierV1(input.sourceEntityId); + const targetEntityId = parseStableIdentifierV1(input.targetEntityId); + const actorId = parseStableIdentifierV1(input.actorId); + if (!sourceEntityId.accepted || !targetEntityId.accepted || !actorId.accepted) return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); + if (actorId.value !== context.actorId) return Object.freeze({ accepted: false as const, code: 'ACTOR_MISMATCH' as const }); + return this.repository.withTransaction(context, async (transaction) => { + const source = await transaction.findLatest(context, sourceEntityId.value); + const target = await transaction.findLatest(context, targetEntityId.value); + if (!source || !target) return Object.freeze({ accepted: false as const, code: 'ENTITY_NOT_FOUND' as const }); + const resolution = mergeBusinessPartyVersionsV1({ source, target, ...input, actorId: actorId.value }); + if (!resolution.accepted) return resolution; + await transaction.saveResolution(context, resolution.value); + return resolution; + }); + } + + public async listVersions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + return this.repository.withTransaction(context, (transaction) => transaction.listVersions(context, entityId)); + } + + public async listResolutions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + return this.repository.withTransaction(context, (transaction) => transaction.listResolutions(context, entityId)); + } +} diff --git a/services/api/test/features/dsm/reference-entity.service.test.ts b/services/api/test/features/dsm/reference-entity.service.test.ts new file mode 100644 index 00000000..2531ef18 --- /dev/null +++ b/services/api/test/features/dsm/reference-entity.service.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryReferenceEntityRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.js'; +import { ReferenceEntityService } from '../../../src/features/dsm/application/reference-entity.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; +const scope = { scopeType: 'workspace' as const, organizationId, workspaceId }; + +function context(idempotencyKey: string) { + const result = createIamTenantContextV1({ tenantScope: scope, actorId, correlationId, idempotencyKey, authorizationEpoch: 1 }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid id'); + return result.value; +} + +function party(entityId: string, versionId: string, displayName: string) { + return { + entityId, versionId, tenantScope: scope, displayName, roles: ['SUPPLIER'], aliases: [], externalIdentifiers: [], canonicalHash: 'a'.repeat(64), createdAt: '2026-01-01T00:00:00.000Z', + } as const; +} + +void test('[DSM-025, DSM-026] reference entities remain immutable and merges are actor-bound', async () => { + const service = new ReferenceEntityService(new InMemoryReferenceEntityRepositoryAdapter()); + const source = await service.create(context('party-source'), party('00000000-0000-4000-8000-000000000020', '00000000-0000-4000-8000-000000000021', 'Source Supplier')); + const target = await service.create(context('party-target'), party('00000000-0000-4000-8000-000000000022', '00000000-0000-4000-8000-000000000023', 'Target Supplier')); + assert.equal(source.accepted, true); + assert.equal(target.accepted, true); + const resolution = await service.merge(context('party-merge'), { + sourceEntityId: source.accepted ? source.value.entityId : '', targetEntityId: target.accepted ? target.value.entityId : '', resolutionId: '00000000-0000-4000-8000-000000000024', actorId, reason: 'Verified duplicate', evidenceId: '00000000-0000-4000-8000-000000000025', resolvedAt: '2026-01-01T00:01:00.000Z', + }); + assert.equal(resolution.accepted, true); + assert.equal((await service.listResolutions(context('party-read'), stable('00000000-0000-4000-8000-000000000020'))).length, 1); + assert.equal((await service.listVersions(context('party-history'), stable('00000000-0000-4000-8000-000000000020'))).length, 1); +}); + +void test('[DSM-027] a merge cannot be authored by a different actor', async () => { + const service = new ReferenceEntityService(new InMemoryReferenceEntityRepositoryAdapter()); + await service.create(context('party-a'), party('00000000-0000-4000-8000-000000000030', '00000000-0000-4000-8000-000000000031', 'A')); + await service.create(context('party-b'), party('00000000-0000-4000-8000-000000000032', '00000000-0000-4000-8000-000000000033', 'B')); + const result = await service.merge(context('party-actor-mismatch'), { sourceEntityId: '00000000-0000-4000-8000-000000000030', targetEntityId: '00000000-0000-4000-8000-000000000032', resolutionId: '00000000-0000-4000-8000-000000000034', actorId: '00000000-0000-4000-8000-000000000099', reason: 'No', evidenceId: '00000000-0000-4000-8000-000000000035', resolvedAt: '2026-01-01T00:01:00.000Z' }); + assert.deepEqual(result, { accepted: false, code: 'ACTOR_MISMATCH' }); +}); From 6d99417c7265bedd3ade7820396e924bd79e7ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:06:15 +0700 Subject: [PATCH 15/44] feat(storage): add DSM mapping and rule-set tables --- .../migration.sql | 55 +++++++++++++++++++ services/api/prisma/schema/dsm.prisma | 50 +++++++++++++++++ services/api/prisma/schema/iae.prisma | 9 ++- services/api/test/prisma-foundation.test.mjs | 14 +++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 services/api/prisma/migrations/20260802110000_dsm_mappings_rules/migration.sql diff --git a/services/api/prisma/migrations/20260802110000_dsm_mappings_rules/migration.sql b/services/api/prisma/migrations/20260802110000_dsm_mappings_rules/migration.sql new file mode 100644 index 00000000..71bf236f --- /dev/null +++ b/services/api/prisma/migrations/20260802110000_dsm_mappings_rules/migration.sql @@ -0,0 +1,55 @@ +-- DSM mappings/rules and explicit tenant scope for governance decisions. +ALTER TABLE "iae"."artifact_lineage" + ADD COLUMN "scope_type" VARCHAR(24) NOT NULL DEFAULT 'workspace', + ADD COLUMN "organization_id" UUID, + ADD COLUMN "workspace_id" UUID, + ADD COLUMN "project_id" UUID; +CREATE INDEX "artifact_lineage_scope_idx" + ON "iae"."artifact_lineage"("organization_id", "workspace_id", "project_id"); + +ALTER TABLE "dsm"."reference_entity_resolutions" + ADD COLUMN "scope_type" VARCHAR(24) NOT NULL DEFAULT 'workspace', + ADD COLUMN "organization_id" UUID, + ADD COLUMN "workspace_id" UUID, + ADD COLUMN "project_id" UUID; +CREATE INDEX "reference_entity_resolutions_scope_idx" + ON "dsm"."reference_entity_resolutions"("organization_id", "workspace_id", "project_id"); + +CREATE TABLE "dsm"."mapping_definitions" ( + "id" UUID NOT NULL, + "dataset_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "source_schema_version_id" UUID NOT NULL, + "target_schema_version_id" UUID NOT NULL, + "steps" JSONB NOT NULL, + "status" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "published_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + "canonical_hash" CHAR(64) NOT NULL, + CONSTRAINT "mapping_definitions_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "mapping_definitions_dataset_version_key" ON "dsm"."mapping_definitions"("dataset_id", "id"); +CREATE INDEX "mapping_definitions_scope_idx" ON "dsm"."mapping_definitions"("organization_id", "workspace_id", "project_id", "dataset_id"); + +CREATE TABLE "dsm"."rule_set_definitions" ( + "id" UUID NOT NULL, + "dataset_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "schema_version_id" UUID NOT NULL, + "rules" JSONB NOT NULL, + "status" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "published_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + "canonical_hash" CHAR(64) NOT NULL, + CONSTRAINT "rule_set_definitions_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "rule_set_definitions_dataset_version_key" ON "dsm"."rule_set_definitions"("dataset_id", "id"); +CREATE INDEX "rule_set_definitions_scope_idx" ON "dsm"."rule_set_definitions"("organization_id", "workspace_id", "project_id", "dataset_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index ebc1d5d4..b635d0d9 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -76,6 +76,10 @@ model ReferenceEntityVersionRecord { /// DSM-027: merge/split decisions never retarget historical bindings. model ReferenceEntityResolutionRecord { id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid sourceEntityId String @map("source_entity_id") @db.Uuid targetEntityId String @map("target_entity_id") @db.Uuid actorId String @map("actor_id") @db.Uuid @@ -85,6 +89,52 @@ model ReferenceEntityResolutionRecord { @@index([sourceEntityId], map: "reference_entity_resolutions_source_idx") @@index([targetEntityId], map: "reference_entity_resolutions_target_idx") + @@index([organizationId, workspaceId, projectId], map: "reference_entity_resolutions_scope_idx") @@map("reference_entity_resolutions") @@schema("dsm") } + +/// DSM-007, DSM-008: immutable declarative mapping versions. +model MappingDefinitionRecord { + id String @id @db.Uuid + datasetId String @map("dataset_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + sourceSchemaVersionId String @map("source_schema_version_id") @db.Uuid + targetSchemaVersionId String @map("target_schema_version_id") @db.Uuid + steps Json + status String @db.VarChar(16) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + publishedAt DateTime? @map("published_at") @db.Timestamptz(6) + revision Int @default(1) + canonicalHash String @map("canonical_hash") @db.Char(64) + + @@unique([datasetId, id], map: "mapping_definitions_dataset_version_key") + @@index([organizationId, workspaceId, projectId, datasetId], map: "mapping_definitions_scope_idx") + @@map("mapping_definitions") + @@schema("dsm") +} + +/// DSM-009, DSM-010, DSM-011: immutable declarative quality rule-set versions. +model RuleSetDefinitionRecord { + id String @id @db.Uuid + datasetId String @map("dataset_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + schemaVersionId String @map("schema_version_id") @db.Uuid + rules Json + status String @db.VarChar(16) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + publishedAt DateTime? @map("published_at") @db.Timestamptz(6) + revision Int @default(1) + canonicalHash String @map("canonical_hash") @db.Char(64) + + @@unique([datasetId, id], map: "rule_set_definitions_dataset_version_key") + @@index([organizationId, workspaceId, projectId, datasetId], map: "rule_set_definitions_scope_idx") + @@map("rule_set_definitions") + @@schema("dsm") +} diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 05c2a09d..4e19043e 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -46,8 +46,12 @@ model InboxItem { /// IAE-007, IAE-012: derived versions retain exact source and coordinate lineage. model ArtifactLineageRecord { - id String @id @db.Uuid - derivedArtifactVersionId String @map("derived_artifact_version_id") @db.Uuid + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + derivedArtifactVersionId String @map("derived_artifact_version_id") @db.Uuid sourceVersionIds Json @map("source_version_ids") processorVersion String @map("processor_version") @db.VarChar(128) recipeVersion String? @map("recipe_version") @db.VarChar(128) @@ -55,6 +59,7 @@ model ArtifactLineageRecord { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@index([derivedArtifactVersionId], map: "artifact_lineage_derived_version_idx") + @@index([organizationId, workspaceId, projectId], map: "artifact_lineage_scope_idx") @@map("artifact_lineage") @@schema("iae") } diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 77d1a12a..0bf85ee5 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -61,6 +61,8 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."rule_set_definitions"/); assert.match(diff.stdout, /CREATE TABLE "jra"\."jobs"/); assert.match(diff.stdout, /CREATE TABLE "jra"\."execution_attempts"/); assert.match(diff.stdout, /CREATE TABLE "jra"\."result_manifests"/); @@ -82,6 +84,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802080000_jra_dispatch_outbox', '20260802090000_jra_recipes', '20260802100000_iae_dsm_governance', + '20260802110000_dsm_mappings_rules', 'migration_lock.toml', ]); const migration = await readFile( @@ -229,4 +232,15 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const mappingRulesMigration = await readFile( + path.join(migrationsDirectory, inventory[12], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "dsm"."mapping_definitions"', + 'CREATE TABLE "dsm"."rule_set_definitions"', + 'CREATE INDEX "artifact_lineage_scope_idx"', + ]) { + assert.match(mappingRulesMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } }); From 19be9c858005907e1673ebfddfa1f85857577cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:09:25 +0700 Subject: [PATCH 16/44] feat(api): expose DSM mapping rules and reference entities --- services/api/openapi/v1.json | 647 ++++++++++++++++++ .../features/dsm/api/mapping.controller.ts | 41 ++ .../api/src/features/dsm/api/mapping.dto.ts | 77 +++ .../dsm/api/reference-entity.controller.ts | 47 ++ .../features/dsm/api/reference-entity.dto.ts | 72 ++ .../features/dsm/api/rule-set.controller.ts | 41 ++ services/api/src/features/dsm/dsm.module.ts | 17 +- services/api/test/openapi.test.ts | 5 + 8 files changed, 946 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/dsm/api/mapping.controller.ts create mode 100644 services/api/src/features/dsm/api/mapping.dto.ts create mode 100644 services/api/src/features/dsm/api/reference-entity.controller.ts create mode 100644 services/api/src/features/dsm/api/reference-entity.dto.ts create mode 100644 services/api/src/features/dsm/api/rule-set.controller.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index cf335d3f..a317b020 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -697,6 +697,544 @@ "summary": "List governed dataset versions visible to the caller", "tags": ["datasets"] } + }, + "/v1/datasets/{datasetId}/mappings": { + "post": { + "operationId": "MappingController.create", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateMappingDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an immutable mapping definition draft", + "tags": ["datasets"] + }, + "get": { + "operationId": "MappingController.list", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List immutable mapping versions", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/rules": { + "post": { + "operationId": "RuleSetController.create", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an immutable quality rule-set draft", + "tags": ["datasets"] + }, + "get": { + "operationId": "RuleSetController.list", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List immutable quality rule-set versions", + "tags": ["datasets"] + } + }, + "/v1/reference-entities": { + "post": { + "operationId": "ReferenceEntityController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReferenceEntityDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an immutable business-party version", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/merge": { + "post": { + "operationId": "ReferenceEntityController.merge", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeReferenceEntityDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Record an explicit business-party merge resolution", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/{entityId}/versions": { + "get": { + "operationId": "ReferenceEntityController.list", + "parameters": [ + { + "name": "entityId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List immutable business-party versions", + "tags": ["reference-entities"] + } } }, "info": { @@ -842,6 +1380,115 @@ "canonicalHash" ] }, + "MappingStepDto": { + "type": "object", + "properties": { + "sourceFieldId": { "type": "string", "format": "uuid" }, + "targetFieldId": { "type": "string", "format": "uuid" }, + "transform": { + "type": "string", + "enum": [ + "IDENTITY", + "TRIM", + "LOWERCASE", + "UPPERCASE", + "PARSE_DECIMAL", + "PARSE_DATE", + "LOOKUP" + ] + }, + "lookupVersionId": { "type": "string", "format": "uuid" } + }, + "required": ["sourceFieldId", "targetFieldId", "transform"] + }, + "CreateMappingDto": { + "type": "object", + "properties": { + "versionId": { "type": "string", "format": "uuid" }, + "sourceSchemaVersionId": { "type": "string", "format": "uuid" }, + "targetSchemaVersionId": { "type": "string", "format": "uuid" }, + "steps": { + "type": "array", + "items": { "$ref": "#/components/schemas/MappingStepDto" } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": [ + "versionId", + "sourceSchemaVersionId", + "targetSchemaVersionId", + "steps", + "createdAt", + "canonicalHash" + ] + }, + "CreateRuleSetDto": { + "type": "object", + "properties": { + "versionId": { "type": "string", "format": "uuid" }, + "schemaVersionId": { "type": "string", "format": "uuid" }, + "rules": { "type": "array", "items": { "type": "object" } }, + "createdAt": { "type": "string", "format": "date-time" }, + "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": [ + "versionId", + "schemaVersionId", + "rules", + "createdAt", + "canonicalHash" + ] + }, + "CreateReferenceEntityDto": { + "type": "object", + "properties": { + "entityId": { "type": "string", "format": "uuid" }, + "versionId": { "type": "string", "format": "uuid" }, + "displayName": { "type": "string", "maxLength": 255 }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["SUPPLIER", "CUSTOMER", "CARRIER", "OTHER"] + } + }, + "aliases": { "type": "array", "items": { "type": "string" } }, + "externalIdentifiers": { + "type": "array", + "items": { "type": "object" } + }, + "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "entityId", + "versionId", + "displayName", + "roles", + "canonicalHash", + "createdAt" + ] + }, + "MergeReferenceEntityDto": { + "type": "object", + "properties": { + "sourceEntityId": { "type": "string", "format": "uuid" }, + "targetEntityId": { "type": "string", "format": "uuid" }, + "resolutionId": { "type": "string", "format": "uuid" }, + "reason": { "type": "string", "maxLength": 512 }, + "evidenceId": { "type": "string", "format": "uuid" }, + "resolvedAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "sourceEntityId", + "targetEntityId", + "resolutionId", + "reason", + "evidenceId", + "resolvedAt" + ] + }, "Identifier": { "title": "Stable UUID Identifier", "description": "An opaque stable UUID identifier.", diff --git a/services/api/src/features/dsm/api/mapping.controller.ts b/services/api/src/features/dsm/api/mapping.controller.ts new file mode 100644 index 00000000..782c2aee --- /dev/null +++ b/services/api/src/features/dsm/api/mapping.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { MAPPING_REPOSITORY_PORT, type MappingRepositoryPortV1 } from '../application/mapping-repository.port.js'; +import { MappingService } from '../application/mapping.service.js'; +import { CreateMappingDto } from './mapping.dto.js'; +import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/datasets/:datasetId/mappings') +export class MappingController { + private readonly mappings: MappingService; + + public constructor( + @Inject(MAPPING_REPOSITORY_PORT) repository: MappingRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.mappings = new MappingService(repository); + } + + @Post() + @ApiOperation({ summary: 'Create an immutable mapping definition draft' }) + @ApiBody({ type: CreateMappingDto }) + async create(@Req() request: unknown, @Param('datasetId') datasetIdInput: string, @Body() input: CreateMappingDto): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.mappings.create(context, { ...input, datasetId: datasetId.value, tenantScope: context.tenantScope }); + } + + @Get() + @ApiOperation({ summary: 'List immutable mapping versions' }) + async list(@Req() request: unknown, @Param('datasetId') datasetIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.mappings.list(context, datasetId.value); + } +} diff --git a/services/api/src/features/dsm/api/mapping.dto.ts b/services/api/src/features/dsm/api/mapping.dto.ts new file mode 100644 index 00000000..0024a3bf --- /dev/null +++ b/services/api/src/features/dsm/api/mapping.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsIn, IsISO8601, IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested } from 'class-validator'; + +export class MappingStepDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sourceFieldId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + targetFieldId!: string; + + @ApiProperty({ enum: ['IDENTITY', 'TRIM', 'LOWERCASE', 'UPPERCASE', 'PARSE_DECIMAL', 'PARSE_DATE', 'LOOKUP'] }) + @IsIn(['IDENTITY', 'TRIM', 'LOWERCASE', 'UPPERCASE', 'PARSE_DECIMAL', 'PARSE_DATE', 'LOOKUP']) + transform!: 'IDENTITY' | 'TRIM' | 'LOWERCASE' | 'UPPERCASE' | 'PARSE_DECIMAL' | 'PARSE_DATE' | 'LOOKUP'; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + lookupVersionId?: string; +} + +export class CreateMappingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + versionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sourceSchemaVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + targetSchemaVersionId!: string; + + @ApiProperty({ type: [MappingStepDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MappingStepDto) + steps!: MappingStepDto[]; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @MinLength(64) + @MaxLength(64) + canonicalHash!: string; +} + +export class CreateRuleSetDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + versionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + schemaVersionId!: string; + + @ApiProperty({ type: [Object] }) + @IsArray() + @IsObject({ each: true }) + rules!: Record[]; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @MinLength(64) + @MaxLength(64) + canonicalHash!: string; +} diff --git a/services/api/src/features/dsm/api/reference-entity.controller.ts b/services/api/src/features/dsm/api/reference-entity.controller.ts new file mode 100644 index 00000000..56d39fa0 --- /dev/null +++ b/services/api/src/features/dsm/api/reference-entity.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { REFERENCE_ENTITY_REPOSITORY_PORT, type ReferenceEntityRepositoryPortV1 } from '../application/reference-entity-repository.port.js'; +import { ReferenceEntityService } from '../application/reference-entity.service.js'; +import { CreateReferenceEntityDto, MergeReferenceEntityDto } from './reference-entity.dto.js'; +import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('reference-entities') +@ApiBearerAuth() +@Controller('v1/reference-entities') +export class ReferenceEntityController { + private readonly entities: ReferenceEntityService; + + public constructor( + @Inject(REFERENCE_ENTITY_REPOSITORY_PORT) repository: ReferenceEntityRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.entities = new ReferenceEntityService(repository); + } + + @Post() + @ApiOperation({ summary: 'Create an immutable business-party version' }) + @ApiBody({ type: CreateReferenceEntityDto }) + async create(@Req() request: unknown, @Body() input: CreateReferenceEntityDto): Promise { + const context = await this.requestContext.resolve(request); + return this.entities.create(context, { ...input, tenantScope: context.tenantScope }); + } + + @Post('merge') + @ApiOperation({ summary: 'Record an explicit business-party merge resolution' }) + @ApiBody({ type: MergeReferenceEntityDto }) + async merge(@Req() request: unknown, @Body() input: MergeReferenceEntityDto): Promise { + const context = await this.requestContext.resolve(request); + return this.entities.merge(context, { ...input, actorId: context.actorId }); + } + + @Get(':entityId/versions') + @ApiOperation({ summary: 'List immutable business-party versions' }) + async list(@Req() request: unknown, @Param('entityId') entityIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const entityId = parseStableIdentifierV1(entityIdInput); + if (!entityId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.entities.listVersions(context, entityId.value); + } +} diff --git a/services/api/src/features/dsm/api/reference-entity.dto.ts b/services/api/src/features/dsm/api/reference-entity.dto.ts new file mode 100644 index 00000000..59c0526d --- /dev/null +++ b/services/api/src/features/dsm/api/reference-entity.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class CreateReferenceEntityDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + entityId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + versionId!: string; + + @ApiProperty({ maxLength: 255 }) + @IsString() + @MinLength(1) + @MaxLength(255) + displayName!: string; + + @ApiProperty({ enum: ['SUPPLIER', 'CUSTOMER', 'CARRIER', 'OTHER'], isArray: true }) + @IsArray() + @IsIn(['SUPPLIER', 'CUSTOMER', 'CARRIER', 'OTHER'], { each: true }) + roles!: ('SUPPLIER' | 'CUSTOMER' | 'CARRIER' | 'OTHER')[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + aliases?: string[]; + + @ApiPropertyOptional({ type: [Object] }) + @IsOptional() + @IsArray() + externalIdentifiers?: { namespace: string; value: string }[]; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @MinLength(64) + @MaxLength(64) + canonicalHash!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} + +export class MergeReferenceEntityDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sourceEntityId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + targetEntityId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + resolutionId!: string; + + @ApiProperty({ maxLength: 512 }) + @IsString() + @MinLength(1) + @MaxLength(512) + reason!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + evidenceId!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + resolvedAt!: string; +} diff --git a/services/api/src/features/dsm/api/rule-set.controller.ts b/services/api/src/features/dsm/api/rule-set.controller.ts new file mode 100644 index 00000000..3b83dc76 --- /dev/null +++ b/services/api/src/features/dsm/api/rule-set.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { RULE_SET_REPOSITORY_PORT, type RuleSetRepositoryPortV1 } from '../application/rule-set-repository.port.js'; +import { RuleSetService } from '../application/rule-set.service.js'; +import { CreateRuleSetDto } from './mapping.dto.js'; +import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/datasets/:datasetId/rules') +export class RuleSetController { + private readonly ruleSets: RuleSetService; + + public constructor( + @Inject(RULE_SET_REPOSITORY_PORT) repository: RuleSetRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.ruleSets = new RuleSetService(repository); + } + + @Post() + @ApiOperation({ summary: 'Create an immutable quality rule-set draft' }) + @ApiBody({ type: CreateRuleSetDto }) + async create(@Req() request: unknown, @Param('datasetId') datasetIdInput: string, @Body() input: CreateRuleSetDto): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.ruleSets.create(context, { ...input, datasetId: datasetId.value, tenantScope: context.tenantScope }); + } + + @Get() + @ApiOperation({ summary: 'List immutable quality rule-set versions' }) + async list(@Req() request: unknown, @Param('datasetId') datasetIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.ruleSets.list(context, datasetId.value); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 523f4221..76bb04e8 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -1,11 +1,20 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { GovernedDatasetController } from './api/governed-dataset.controller.js'; +import { MappingController } from './api/mapping.controller.js'; +import { ReferenceEntityController } from './api/reference-entity.controller.js'; +import { RuleSetController } from './api/rule-set.controller.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; +import { InMemoryMappingRepositoryAdapter } from './adapter/in-memory-mapping-repository.adapter.js'; +import { InMemoryReferenceEntityRepositoryAdapter } from './adapter/in-memory-reference-entity-repository.adapter.js'; +import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; import { GOVERNED_DATASET_REPOSITORY_PORT, type GovernedDatasetRepositoryPortV1, } from './application/governed-dataset-repository.port.js'; +import { MAPPING_REPOSITORY_PORT, type MappingRepositoryPortV1 } from './application/mapping-repository.port.js'; +import { REFERENCE_ENTITY_REPOSITORY_PORT, type ReferenceEntityRepositoryPortV1 } from './application/reference-entity-repository.port.js'; +import { RULE_SET_REPOSITORY_PORT, type RuleSetRepositoryPortV1 } from './application/rule-set-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -14,6 +23,9 @@ import { export interface DsmModuleOptions { readonly governedDatasetRepository?: GovernedDatasetRepositoryPortV1; + readonly mappingRepository?: MappingRepositoryPortV1; + readonly ruleSetRepository?: RuleSetRepositoryPortV1; + readonly referenceEntityRepository?: ReferenceEntityRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -22,12 +34,15 @@ export class DsmModule { public static register(options: DsmModuleOptions = {}): DynamicModule { return { module: DsmModule, - controllers: [GovernedDatasetController], + controllers: [GovernedDatasetController, MappingController, RuleSetController, ReferenceEntityController], providers: [ { provide: GOVERNED_DATASET_REPOSITORY_PORT, useValue: options.governedDatasetRepository ?? new InMemoryGovernedDatasetRepositoryAdapter(), }, + { provide: MAPPING_REPOSITORY_PORT, useValue: options.mappingRepository ?? new InMemoryMappingRepositoryAdapter() }, + { provide: RULE_SET_REPOSITORY_PORT, useValue: options.ruleSetRepository ?? new InMemoryRuleSetRepositoryAdapter() }, + { provide: REFERENCE_ENTITY_REPOSITORY_PORT, useValue: options.referenceEntityRepository ?? new InMemoryReferenceEntityRepositoryAdapter() }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 19371e5b..d68d9893 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,7 +66,12 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/inbox', '/v1/auth/sign-in', '/v1/datasets', + '/v1/datasets/{datasetId}/mappings', + '/v1/datasets/{datasetId}/rules', '/v1/datasets/{datasetId}/versions', + '/v1/reference-entities', + '/v1/reference-entities/merge', + '/v1/reference-entities/{entityId}/versions', '/v1/system/compatibility', '/v1/system/compatibility/check', ]); From a0ef706b25d353faff7f6b157f5da57e30de3ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:10:38 +0700 Subject: [PATCH 17/44] fix(api): clear strict lint violations --- .../features/dsm/application/governed-dataset.service.ts | 2 +- .../api/src/platform/http/request-tenant-context.port.ts | 3 ++- .../api/test/features/iae/artifact-intake.service.test.ts | 7 ------- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/services/api/src/features/dsm/application/governed-dataset.service.ts b/services/api/src/features/dsm/application/governed-dataset.service.ts index ea949f77..14363e3e 100644 --- a/services/api/src/features/dsm/application/governed-dataset.service.ts +++ b/services/api/src/features/dsm/application/governed-dataset.service.ts @@ -6,7 +6,7 @@ import { type GovernedDatasetDefinitionV1, type SchemaCompatibilityV1, } from '@databreeze/domain/dataset-governance/v1'; -import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { GovernedDatasetRepositoryPortV1 } from './governed-dataset-repository.port.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 5ec4529c..05ae6ade 100644 --- a/services/api/src/platform/http/request-tenant-context.port.ts +++ b/services/api/src/platform/http/request-tenant-context.port.ts @@ -9,7 +9,8 @@ export interface RequestTenantContextPortV1 { /** Safe default until the IAM bearer/session adapter is configured by the host. */ export class UnavailableRequestTenantContextAdapter implements RequestTenantContextPortV1 { - public async resolve(_request: unknown): Promise { + public async resolve(request: unknown): Promise { + void request; await Promise.resolve(); throw new Error('AUTHENTICATED_CONTEXT_UNAVAILABLE'); } diff --git a/services/api/test/features/iae/artifact-intake.service.test.ts b/services/api/test/features/iae/artifact-intake.service.test.ts index 6828d068..4820df37 100644 --- a/services/api/test/features/iae/artifact-intake.service.test.ts +++ b/services/api/test/features/iae/artifact-intake.service.test.ts @@ -51,13 +51,6 @@ const artifactResult = createArtifactVersionV1({ if (!artifactResult.accepted) throw new Error('invalid artifact fixture'); const artifact = artifactResult.value; -function stable(value: string) { - const result = parseStableIdentifierV1(value); - assert.equal(result.accepted, true); - if (!result.accepted) throw new Error('invalid identifier'); - return result.value; -} - void test('[IAE-001] create returns the same inbox item for a repeated key', async () => { const service = new ArtifactIntakeService(new InMemoryArtifactIntakeRepositoryAdapter()); const first = await service.create(context(workspaceId, 'create-1'), inbox); From dcbf3a3db3548265c0db8c8d746569738c21d07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:11:00 +0700 Subject: [PATCH 18/44] fix(api): remove stale intake test import --- services/api/test/features/iae/artifact-intake.service.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/api/test/features/iae/artifact-intake.service.test.ts b/services/api/test/features/iae/artifact-intake.service.test.ts index 4820df37..0f1fefd7 100644 --- a/services/api/test/features/iae/artifact-intake.service.test.ts +++ b/services/api/test/features/iae/artifact-intake.service.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { createArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; import { InMemoryArtifactIntakeRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.js'; From cb0d5543699239c06531380bfaef27c4df03f1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:13:19 +0700 Subject: [PATCH 19/44] feat(iae): add expiring evidence access grants --- packages/domain/package.json | 4 + packages/domain/src/evidence-grant/v1.ts | 120 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../domain/test/evidence-grant-v1.test.mjs | 31 +++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 161 insertions(+) create mode 100644 packages/domain/src/evidence-grant/v1.ts create mode 100644 packages/domain/test/evidence-grant-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index e83e06f8..1a11008f 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -103,6 +103,10 @@ "./rule-set/v1": { "types": "./src/rule-set/v1.ts", "import": "./dist/rule-set/v1.js" + }, + "./evidence-grant/v1": { + "types": "./src/evidence-grant/v1.ts", + "import": "./dist/evidence-grant/v1.js" } }, "scripts": { diff --git a/packages/domain/src/evidence-grant/v1.ts b/packages/domain/src/evidence-grant/v1.ts new file mode 100644 index 00000000..46880929 --- /dev/null +++ b/packages/domain/src/evidence-grant/v1.ts @@ -0,0 +1,120 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { ArtifactDataModeV1, EvidenceSourceStateV1 } from '../artifact/v1.js'; + +/** IAE-005, IAE-006: short-lived, opaque evidence grants. */ +export const EVIDENCE_GRANT_SCHEMA_VERSION_V1 = 1 as const; +export type EvidenceGrantActionV1 = 'COORDINATE' | 'EXCERPT' | 'OPEN_ON_DEVICE'; + +export interface EvidenceAccessGrantV1 { + readonly schemaVersion: typeof EVIDENCE_GRANT_SCHEMA_VERSION_V1; + readonly grantId: StableIdentifierV1; + readonly evidenceId: StableIdentifierV1; + readonly artifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly recipientDeviceId: StableIdentifierV1; + readonly action: EvidenceGrantActionV1; + readonly issuedAt: StrictUtcTimestampV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly authorizationEpoch: number; + readonly maxExcerptBytes: number; +} + +export type EvidenceGrantErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_ACTION' + | 'INVALID_EPOCH' + | 'INVALID_BYTES' + | 'EXPIRY_TOO_LONG' + | 'LOCAL_CONTENT_LEAK' + | 'SOURCE_UNAVAILABLE'; + +export type EvidenceGrantResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: EvidenceGrantErrorCodeV1 }; + +function accepted(value: TValue): EvidenceGrantResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: EvidenceGrantErrorCodeV1): EvidenceGrantResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const result = parseTenantScopeV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +export function createEvidenceAccessGrantV1(input: { + readonly grantId: unknown; + readonly evidenceId: unknown; + readonly artifactVersionId: unknown; + readonly tenantScope: unknown; + readonly recipientDeviceId: unknown; + readonly action: unknown; + readonly issuedAt: unknown; + readonly expiresAt: unknown; + readonly authorizationEpoch: unknown; + readonly maxExcerptBytes?: unknown; + readonly artifactDataMode: ArtifactDataModeV1; + readonly sourceState: EvidenceSourceStateV1; +}): EvidenceGrantResultV1 { + const grantId = identifier(input.grantId); + const evidenceId = identifier(input.evidenceId); + const artifactVersionId = identifier(input.artifactVersionId); + const tenantScope = scope(input.tenantScope); + const recipientDeviceId = identifier(input.recipientDeviceId); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = timestamp(input.expiresAt); + if (!grantId || !evidenceId || !artifactVersionId || !recipientDeviceId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!issuedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(issuedAt)) return rejected('INVALID_TIMESTAMP'); + if (Date.parse(expiresAt) - Date.parse(issuedAt) > 15 * 60 * 1000) return rejected('EXPIRY_TOO_LONG'); + if (!['COORDINATE', 'EXCERPT', 'OPEN_ON_DEVICE'].includes(input.action as string)) return rejected('INVALID_ACTION'); + if (!['AVAILABLE', 'SOURCE_OFFLINE', 'DELETED'].includes(input.sourceState)) return rejected('SOURCE_UNAVAILABLE'); + if (input.action === 'OPEN_ON_DEVICE' && input.artifactDataMode !== 'Local') return rejected('INVALID_ACTION'); + if (input.action === 'EXCERPT' && input.artifactDataMode === 'Local') return rejected('LOCAL_CONTENT_LEAK'); + if (input.action === 'EXCERPT' && input.sourceState !== 'AVAILABLE') return rejected('SOURCE_UNAVAILABLE'); + if (typeof input.authorizationEpoch !== 'number' || !Number.isSafeInteger(input.authorizationEpoch) || input.authorizationEpoch < 1) return rejected('INVALID_EPOCH'); + const maxExcerptBytes = input.maxExcerptBytes ?? (input.action === 'EXCERPT' ? 512 : 0); + if (typeof maxExcerptBytes !== 'number' || !Number.isSafeInteger(maxExcerptBytes) || maxExcerptBytes < 0 || maxExcerptBytes > 4096) return rejected('INVALID_BYTES'); + if (input.action !== 'EXCERPT' && maxExcerptBytes !== 0) return rejected('INVALID_BYTES'); + return accepted(Object.freeze({ + schemaVersion: EVIDENCE_GRANT_SCHEMA_VERSION_V1, + grantId, + evidenceId, + artifactVersionId, + tenantScope, + recipientDeviceId, + action: input.action as EvidenceGrantActionV1, + issuedAt, + expiresAt, + authorizationEpoch: input.authorizationEpoch, + maxExcerptBytes, + })); +} + +export function evidenceGrantMatchesScopeV1(grant: EvidenceAccessGrantV1, scopeInput: unknown): boolean { + const candidate = scope(scopeInput); + return candidate !== undefined && tenantScopesEqualV1(candidate, grant.tenantScope); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index db052b26..baf6bf7d 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -15,6 +15,7 @@ export * from './finding/v1.js'; export * from './reference-entity/v1.js'; export * from './mapping/v1.js'; export * from './rule-set/v1.js'; +export * from './evidence-grant/v1.js'; export * from './identity/v1.js'; export * from './entitlements/v1.js'; export * from './mfa/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index fb1618ad..26fee4d8 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -21,6 +21,7 @@ const [ referenceEntity, mapping, ruleSet, + evidenceGrant, ] = await Promise.all([ import('@databreeze/domain/v1'), import('@databreeze/domain/permissions/v1'), @@ -42,6 +43,7 @@ const [ import('@databreeze/domain/reference-entity/v1'), import('@databreeze/domain/mapping/v1'), import('@databreeze/domain/rule-set/v1'), + import('@databreeze/domain/evidence-grant/v1'), ]); assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); @@ -65,4 +67,5 @@ assert.equal(recipe.RECIPE_SCHEMA_VERSION_V1, 1); assert.equal(referenceEntity.REFERENCE_ENTITY_SCHEMA_VERSION_V1, 1); assert.equal(mapping.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(ruleSet.RULE_SET_SCHEMA_VERSION_V1, 1); + assert.equal(evidenceGrant.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/evidence-grant-v1.test.mjs b/packages/domain/test/evidence-grant-v1.test.mjs new file mode 100644 index 00000000..87e31082 --- /dev/null +++ b/packages/domain/test/evidence-grant-v1.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createEvidenceAccessGrantV1 } from '../dist/evidence-grant/v1.js'; + +const scope = { scopeType: 'workspace', organizationId: '00000000-0000-4000-8000-000000000001', workspaceId: '00000000-0000-4000-8000-000000000002' }; +const base = { + grantId: '00000000-0000-4000-8000-000000000010', evidenceId: '00000000-0000-4000-8000-000000000011', artifactVersionId: '00000000-0000-4000-8000-000000000012', tenantScope: scope, recipientDeviceId: '00000000-0000-4000-8000-000000000013', issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T00:05:00.000Z', authorizationEpoch: 2, artifactDataMode: 'Hybrid', sourceState: 'AVAILABLE', +}; + +void test('[IAE-005] grants are short-lived and bind an action to a device epoch', () => { + const result = createEvidenceAccessGrantV1({ ...base, action: 'EXCERPT' }); + assert.equal(result.accepted, true); + if (result.accepted) { + assert.equal(result.value.recipientDeviceId, base.recipientDeviceId); + assert.equal(result.value.authorizationEpoch, 2); + assert.equal(result.value.maxExcerptBytes, 512); + } +}); + +void test('[IAE-006] Local evidence cannot create excerpt or cloud-open grants', () => { + assert.deepEqual(createEvidenceAccessGrantV1({ ...base, action: 'EXCERPT', artifactDataMode: 'Local' }), { accepted: false, code: 'LOCAL_CONTENT_LEAK' }); + const local = createEvidenceAccessGrantV1({ ...base, action: 'OPEN_ON_DEVICE', artifactDataMode: 'Local' }); + assert.equal(local.accepted, true); + if (local.accepted) assert.equal(local.value.action, 'OPEN_ON_DEVICE'); +}); + +void test('[IAE-005] grants reject long expiry and unavailable excerpts', () => { + assert.deepEqual(createEvidenceAccessGrantV1({ ...base, action: 'COORDINATE', expiresAt: '2026-01-01T00:16:00.000Z' }), { accepted: false, code: 'EXPIRY_TOO_LONG' }); + assert.deepEqual(createEvidenceAccessGrantV1({ ...base, action: 'EXCERPT', sourceState: 'SOURCE_OFFLINE' }), { accepted: false, code: 'SOURCE_UNAVAILABLE' }); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 9fc763ff..45821a0a 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -34,6 +34,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './reference-entity/v1', './mapping/v1', './rule-set/v1', + './evidence-grant/v1', ]); for (const entry of Object.values(manifest.exports)) { @@ -60,6 +61,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); assert.equal(aggregate.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.RULE_SET_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); }); test('[IAM-004] does not expose an unversioned package root', async () => { From 6d4ac0cb4f71a780b1242646e3cb9950debddca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:15:24 +0700 Subject: [PATCH 20/44] feat(iae): issue and revoke scoped evidence grants --- ...emory-evidence-grant-repository.adapter.ts | 62 +++++++++++++++++++ .../evidence-grant-repository.port.ts | 17 +++++ .../iae/application/evidence-grant.service.ts | 55 ++++++++++++++++ .../iae/evidence-grant.service.test.ts | 41 ++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts create mode 100644 services/api/src/features/iae/application/evidence-grant-repository.port.ts create mode 100644 services/api/src/features/iae/application/evidence-grant.service.ts create mode 100644 services/api/test/features/iae/evidence-grant.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts new file mode 100644 index 00000000..c98717f7 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts @@ -0,0 +1,62 @@ +import { tenantScopeContainsV1, type EvidenceAccessGrantV1, type TenantScopeV1 } from '@databreeze/domain/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { EvidenceGrantRepositoryPortV1, EvidenceGrantTransactionPortV1 } from '../application/evidence-grant-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(grant: EvidenceAccessGrantV1): EvidenceAccessGrantV1 { + return Object.freeze({ ...grant, tenantScope: Object.freeze({ ...grant.tenantScope }) }); +} + +export class InMemoryEvidenceGrantRepositoryAdapter implements EvidenceGrantRepositoryPortV1 { + private grants = new Map(); + private revoked = new Set(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, grant.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.grants.get(grant.grantId); + if (existing && JSON.stringify(existing) !== JSON.stringify(grant)) throw new Error('IAE_IMMUTABLE_GRANT'); + this.grants.set(grant.grantId, clone(grant)); + } + + public async find(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise { + await Promise.resolve(); + const grant = this.grants.get(grantId); + return grant && visible(context.tenantScope, grant.tenantScope) ? clone(grant) : undefined; + } + + public async revoke(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise { + const grant = await this.find(context, grantId); + if (!grant) throw new Error('IAE_GRANT_NOT_FOUND'); + this.revoked.add(grantId); + } + + public async isRevoked(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise { + const grant = await this.find(context, grantId); + return grant !== undefined && this.revoked.has(grantId); + } + + public async withTransaction(context: IamTenantContextV1, work: (transaction: EvidenceGrantTransactionPortV1) => Promise): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { release = resolve; }); + await previous; + const beforeGrants = new Map(this.grants); + const beforeRevoked = new Set(this.revoked); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this), revoke: this.revoke.bind(this), isRevoked: this.isRevoked.bind(this) }); + } catch (error) { + this.grants = beforeGrants; + this.revoked = beforeRevoked; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/application/evidence-grant-repository.port.ts b/services/api/src/features/iae/application/evidence-grant-repository.port.ts new file mode 100644 index 00000000..9eee675b --- /dev/null +++ b/services/api/src/features/iae/application/evidence-grant-repository.port.ts @@ -0,0 +1,17 @@ +import type { EvidenceAccessGrantV1 } from '@databreeze/domain/evidence-grant/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const EVIDENCE_GRANT_REPOSITORY_PORT = Symbol('EVIDENCE_GRANT_REPOSITORY_PORT'); + +export interface EvidenceGrantTransactionPortV1 { + save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise; + find(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise; + revoke(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise; + isRevoked(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise; +} + +export interface EvidenceGrantRepositoryPortV1 extends EvidenceGrantTransactionPortV1 { + withTransaction(context: IamTenantContextV1, work: (transaction: EvidenceGrantTransactionPortV1) => Promise): Promise; +} diff --git a/services/api/src/features/iae/application/evidence-grant.service.ts b/services/api/src/features/iae/application/evidence-grant.service.ts new file mode 100644 index 00000000..98bec637 --- /dev/null +++ b/services/api/src/features/iae/application/evidence-grant.service.ts @@ -0,0 +1,55 @@ +import { createEvidenceAccessGrantV1, type EvidenceAccessGrantV1, type EvidenceGrantResultV1 } from '@databreeze/domain/evidence-grant/v1'; +import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { EvidenceGrantRepositoryPortV1 } from './evidence-grant-repository.port.js'; + +export type EvidenceGrantServiceErrorV1 = 'GRANT_NOT_FOUND' | 'GRANT_REVOKED' | 'GRANT_EXPIRED' | 'DEVICE_MISMATCH' | 'EPOCH_MISMATCH'; +export type EvidenceGrantServiceResultV1 = EvidenceGrantResultV1 | { readonly accepted: false; readonly code: EvidenceGrantServiceErrorV1 }; + +export class EvidenceGrantService { + public constructor(private readonly repository: EvidenceGrantRepositoryPortV1) {} + + public async issue(context: IamTenantContextV1, input: Omit[0], 'tenantScope'>): Promise> { + const created = createEvidenceAccessGrantV1({ ...input, tenantScope: context.tenantScope }); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.grantId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) return created; + throw new Error('IAE_IMMUTABLE_GRANT'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async resolve(context: IamTenantContextV1, input: { readonly grantId: unknown; readonly recipientDeviceId: unknown; readonly authorizationEpoch: unknown; readonly now: unknown }): Promise> { + const grantId = parseStableIdentifierV1(input.grantId); + const recipientDeviceId = parseStableIdentifierV1(input.recipientDeviceId); + if (!grantId.accepted || !recipientDeviceId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; + if (typeof input.authorizationEpoch !== 'number' || !Number.isSafeInteger(input.authorizationEpoch) || input.authorizationEpoch < 1) return { accepted: false, code: 'INVALID_EPOCH' }; + if (typeof input.now !== 'string' || Number.isNaN(Date.parse(input.now))) return { accepted: false, code: 'INVALID_TIMESTAMP' }; + const now = input.now; + return this.repository.withTransaction(context, async (transaction) => { + const grant = await transaction.find(context, grantId.value); + if (!grant) return { accepted: false as const, code: 'GRANT_NOT_FOUND' as const }; + if (await transaction.isRevoked(context, grant.grantId)) return { accepted: false as const, code: 'GRANT_REVOKED' as const }; + if (grant.recipientDeviceId !== recipientDeviceId.value) return { accepted: false as const, code: 'DEVICE_MISMATCH' as const }; + if (grant.authorizationEpoch !== input.authorizationEpoch) return { accepted: false as const, code: 'EPOCH_MISMATCH' as const }; + if (Date.parse(now) >= Date.parse(grant.expiresAt)) return { accepted: false as const, code: 'GRANT_EXPIRED' as const }; + return { accepted: true as const, value: grant }; + }); + } + + public async revoke(context: IamTenantContextV1, grantIdInput: unknown): Promise> { + const grantId = parseStableIdentifierV1(grantIdInput); + if (!grantId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; + return this.repository.withTransaction(context, async (transaction) => { + const grant = await transaction.find(context, grantId.value); + if (!grant) return { accepted: false as const, code: 'GRANT_NOT_FOUND' as const }; + await transaction.revoke(context, grant.grantId); + return { accepted: true as const, value: true }; + }); + } +} diff --git a/services/api/test/features/iae/evidence-grant.service.test.ts b/services/api/test/features/iae/evidence-grant.service.test.ts new file mode 100644 index 00000000..e9c1def9 --- /dev/null +++ b/services/api/test/features/iae/evidence-grant.service.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { EvidenceGrantService } from '../../../src/features/iae/application/evidence-grant.service.js'; +import { InMemoryEvidenceGrantRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; +const deviceId = '00000000-0000-4000-8000-000000000012'; + +function context(idempotencyKey: string) { + const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, actorId, correlationId, idempotencyKey, authorizationEpoch: 2 }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +const input = { + grantId: '00000000-0000-4000-8000-000000000020', evidenceId: '00000000-0000-4000-8000-000000000021', artifactVersionId: '00000000-0000-4000-8000-000000000022', recipientDeviceId: deviceId, action: 'EXCERPT', issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T00:05:00.000Z', authorizationEpoch: 2, artifactDataMode: 'Hybrid', sourceState: 'AVAILABLE', +} as const; + +void test('[IAE-005] service issues and resolves an epoch-bound grant', async () => { + const service = new EvidenceGrantService(new InMemoryEvidenceGrantRepositoryAdapter()); + const issued = await service.issue(context('grant-issue'), input); + assert.equal(issued.accepted, true); + const resolved = await service.resolve(context('grant-resolve'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 2, now: '2026-01-01T00:01:00.000Z' }); + assert.equal(resolved.accepted, true); +}); + +void test('[IAE-005, IAM-020] revoked, expired, and mismatched grants fail closed', async () => { + const service = new EvidenceGrantService(new InMemoryEvidenceGrantRepositoryAdapter()); + await service.issue(context('grant-fail'), input); + assert.deepEqual(await service.resolve(context('grant-device'), { grantId: input.grantId, recipientDeviceId: '00000000-0000-4000-8000-000000000099', authorizationEpoch: 2, now: '2026-01-01T00:01:00.000Z' }), { accepted: false, code: 'DEVICE_MISMATCH' }); + assert.deepEqual(await service.resolve(context('grant-epoch'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 3, now: '2026-01-01T00:01:00.000Z' }), { accepted: false, code: 'EPOCH_MISMATCH' }); + assert.deepEqual(await service.resolve(context('grant-expired'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 2, now: '2026-01-01T00:06:00.000Z' }), { accepted: false, code: 'GRANT_EXPIRED' }); + await service.revoke(context('grant-revoke'), input.grantId); + assert.deepEqual(await service.resolve(context('grant-revoked'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 2, now: '2026-01-01T00:01:00.000Z' }), { accepted: false, code: 'GRANT_REVOKED' }); +}); From 4ce9d96486c5b233bc2751de306c768be2fff3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:18:47 +0700 Subject: [PATCH 21/44] fix(iae): bind evidence grants to authenticated epochs --- .../iae/application/evidence-grant.service.ts | 44 +++++++++++++++++-- .../iae/evidence-grant.service.test.ts | 5 +++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/iae/application/evidence-grant.service.ts b/services/api/src/features/iae/application/evidence-grant.service.ts index 98bec637..98aa1bfd 100644 --- a/services/api/src/features/iae/application/evidence-grant.service.ts +++ b/services/api/src/features/iae/application/evidence-grant.service.ts @@ -1,16 +1,21 @@ import { createEvidenceAccessGrantV1, type EvidenceAccessGrantV1, type EvidenceGrantResultV1 } from '@databreeze/domain/evidence-grant/v1'; -import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; import type { EvidenceGrantRepositoryPortV1 } from './evidence-grant-repository.port.js'; -export type EvidenceGrantServiceErrorV1 = 'GRANT_NOT_FOUND' | 'GRANT_REVOKED' | 'GRANT_EXPIRED' | 'DEVICE_MISMATCH' | 'EPOCH_MISMATCH'; +export type EvidenceGrantServiceErrorV1 = 'GRANT_NOT_FOUND' | 'GRANT_REVOKED' | 'GRANT_EXPIRED' | 'DEVICE_MISMATCH' | 'EPOCH_MISMATCH' | 'EVIDENCE_NOT_FOUND' | 'ARTIFACT_REPOSITORY_UNAVAILABLE'; export type EvidenceGrantServiceResultV1 = EvidenceGrantResultV1 | { readonly accepted: false; readonly code: EvidenceGrantServiceErrorV1 }; export class EvidenceGrantService { - public constructor(private readonly repository: EvidenceGrantRepositoryPortV1) {} + public constructor( + private readonly repository: EvidenceGrantRepositoryPortV1, + private readonly artifactRepository?: ArtifactRepositoryPortV1, + ) {} public async issue(context: IamTenantContextV1, input: Omit[0], 'tenantScope'>): Promise> { + if (input.authorizationEpoch !== context.authorizationEpoch) return { accepted: false, code: 'EPOCH_MISMATCH' }; const created = createEvidenceAccessGrantV1({ ...input, tenantScope: context.tenantScope }); if (!created.accepted) return created; return this.repository.withTransaction(context, async (transaction) => { @@ -29,6 +34,7 @@ export class EvidenceGrantService { const recipientDeviceId = parseStableIdentifierV1(input.recipientDeviceId); if (!grantId.accepted || !recipientDeviceId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; if (typeof input.authorizationEpoch !== 'number' || !Number.isSafeInteger(input.authorizationEpoch) || input.authorizationEpoch < 1) return { accepted: false, code: 'INVALID_EPOCH' }; + if (input.authorizationEpoch !== context.authorizationEpoch) return { accepted: false, code: 'EPOCH_MISMATCH' }; if (typeof input.now !== 'string' || Number.isNaN(Date.parse(input.now))) return { accepted: false, code: 'INVALID_TIMESTAMP' }; const now = input.now; return this.repository.withTransaction(context, async (transaction) => { @@ -42,6 +48,38 @@ export class EvidenceGrantService { }); } + /** Derives data mode and source state from the exact immutable artifact record. */ + public async issueForEvidence(context: IamTenantContextV1, input: { + readonly versionId: unknown; + readonly evidenceId: unknown; + readonly grantId: unknown; + readonly recipientDeviceId: unknown; + readonly action: unknown; + readonly issuedAt: unknown; + readonly expiresAt: unknown; + readonly authorizationEpoch: unknown; + readonly maxExcerptBytes?: unknown; + }): Promise> { + if (!this.artifactRepository) return { accepted: false, code: 'ARTIFACT_REPOSITORY_UNAVAILABLE' }; + const versionId = parseStableIdentifierV1(input.versionId); + const evidenceId = parseStableIdentifierV1(input.evidenceId); + if (!versionId.accepted || !evidenceId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; + const source = await this.artifactRepository.withTransaction(context, async (transaction) => { + const version = await transaction.findVersion(context, versionId.value); + if (!version) return undefined; + const evidence = (await transaction.listEvidence(context, versionId.value)).find((candidate) => candidate.evidenceId === evidenceId.value); + return evidence ? { dataMode: version.dataMode, sourceState: evidence.sourceState } : undefined; + }); + if (!source) return { accepted: false, code: 'EVIDENCE_NOT_FOUND' }; + return this.issue(context, { + ...input, + artifactVersionId: versionId.value, + evidenceId: evidenceId.value, + artifactDataMode: source.dataMode, + sourceState: source.sourceState, + }); + } + public async revoke(context: IamTenantContextV1, grantIdInput: unknown): Promise> { const grantId = parseStableIdentifierV1(grantIdInput); if (!grantId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; diff --git a/services/api/test/features/iae/evidence-grant.service.test.ts b/services/api/test/features/iae/evidence-grant.service.test.ts index e9c1def9..81f6cf69 100644 --- a/services/api/test/features/iae/evidence-grant.service.test.ts +++ b/services/api/test/features/iae/evidence-grant.service.test.ts @@ -39,3 +39,8 @@ void test('[IAE-005, IAM-020] revoked, expired, and mismatched grants fail close await service.revoke(context('grant-revoke'), input.grantId); assert.deepEqual(await service.resolve(context('grant-revoked'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 2, now: '2026-01-01T00:01:00.000Z' }), { accepted: false, code: 'GRANT_REVOKED' }); }); + +void test('[IAM-020] issuing a grant with a stale authorization epoch is rejected', async () => { + const service = new EvidenceGrantService(new InMemoryEvidenceGrantRepositoryAdapter()); + assert.deepEqual(await service.issue(context('grant-stale-epoch'), { ...input, authorizationEpoch: 1 }), { accepted: false, code: 'EPOCH_MISMATCH' }); +}); From 2766ad1e37b6bd46136e19c68927c536882bf0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:18:54 +0700 Subject: [PATCH 22/44] feat(api): expose exact evidence grant lifecycle --- services/api/openapi/v1.json | 187 ++++++++++++++++++ .../iae/api/evidence-grant.controller.ts | 38 ++++ .../features/iae/api/evidence-grant.dto.ts | 36 ++++ services/api/src/features/iae/iae.module.ts | 19 +- services/api/test/openapi.test.ts | 2 + 5 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/iae/api/evidence-grant.controller.ts create mode 100644 services/api/src/features/iae/api/evidence-grant.dto.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index a317b020..c8b78ac4 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -546,6 +546,170 @@ "tags": ["artifacts"] } }, + "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { + "post": { + "operationId": "EvidenceGrantController.issue", + "parameters": [ + { + "name": "versionId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "evidenceId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEvidenceGrantDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Issue a short-lived exact-evidence access grant", + "tags": ["artifacts"] + } + }, + "/v1/artifacts/evidence-grants/{grantId}": { + "delete": { + "operationId": "EvidenceGrantController.revoke", + "parameters": [ + { + "name": "grantId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Revoke an evidence access grant", + "tags": ["artifacts"] + } + }, "/v1/datasets": { "post": { "operationId": "GovernedDatasetController.create", @@ -1333,6 +1497,29 @@ }, "required": ["inboxItemId", "artifactVersionId", "createdAt"] }, + "CreateEvidenceGrantDto": { + "type": "object", + "properties": { + "grantId": { "type": "string", "format": "uuid" }, + "recipientDeviceId": { "type": "string", "format": "uuid" }, + "action": { + "type": "string", + "enum": ["COORDINATE", "EXCERPT", "OPEN_ON_DEVICE"] + }, + "issuedAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" }, + "authorizationEpoch": { "type": "number", "minimum": 1 }, + "maxExcerptBytes": { "type": "number", "minimum": 0, "maximum": 4096 } + }, + "required": [ + "grantId", + "recipientDeviceId", + "action", + "issuedAt", + "expiresAt", + "authorizationEpoch" + ] + }, "GovernedDatasetFieldDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iae/api/evidence-grant.controller.ts b/services/api/src/features/iae/api/evidence-grant.controller.ts new file mode 100644 index 00000000..36da2ff6 --- /dev/null +++ b/services/api/src/features/iae/api/evidence-grant.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Delete, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ARTIFACT_REPOSITORY_PORT, type ArtifactRepositoryPortV1 } from '../application/artifact-repository.port.js'; +import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1 } from '../application/evidence-grant-repository.port.js'; +import { EvidenceGrantService } from '../application/evidence-grant.service.js'; +import { CreateEvidenceGrantDto } from './evidence-grant.dto.js'; +import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifacts') +export class EvidenceGrantController { + private readonly grants: EvidenceGrantService; + + public constructor( + @Inject(EVIDENCE_GRANT_REPOSITORY_PORT) grantRepository: EvidenceGrantRepositoryPortV1, + @Inject(ARTIFACT_REPOSITORY_PORT) artifactRepository: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.grants = new EvidenceGrantService(grantRepository, artifactRepository); + } + + @Post(':versionId/evidence/:evidenceId/grants') + @ApiOperation({ summary: 'Issue a short-lived exact-evidence access grant' }) + @ApiBody({ type: CreateEvidenceGrantDto }) + async issue(@Req() request: unknown, @Param('versionId') versionId: string, @Param('evidenceId') evidenceId: string, @Body() input: CreateEvidenceGrantDto): Promise { + const context = await this.requestContext.resolve(request); + return this.grants.issueForEvidence(context, { ...input, versionId, evidenceId }); + } + + @Delete('/evidence-grants/:grantId') + @ApiOperation({ summary: 'Revoke an evidence access grant' }) + async revoke(@Req() request: unknown, @Param('grantId') grantId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.grants.revoke(context, grantId); + } +} diff --git a/services/api/src/features/iae/api/evidence-grant.dto.ts b/services/api/src/features/iae/api/evidence-grant.dto.ts new file mode 100644 index 00000000..75097fac --- /dev/null +++ b/services/api/src/features/iae/api/evidence-grant.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsISO8601, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; + +export class CreateEvidenceGrantDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + grantId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + recipientDeviceId!: string; + + @ApiProperty({ enum: ['COORDINATE', 'EXCERPT', 'OPEN_ON_DEVICE'] }) + @IsIn(['COORDINATE', 'EXCERPT', 'OPEN_ON_DEVICE']) + action!: 'COORDINATE' | 'EXCERPT' | 'OPEN_ON_DEVICE'; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + issuedAt!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + expiresAt!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + authorizationEpoch!: number; + + @ApiPropertyOptional({ minimum: 0, maximum: 4096 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(4096) + maxExcerptBytes?: number; +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 0799fd0e..5ea7f509 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -1,11 +1,16 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { InboxController } from './api/inbox.controller.js'; +import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; +import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; +import { InMemoryEvidenceGrantRepositoryAdapter } from './adapter/in-memory-evidence-grant-repository.adapter.js'; import { ARTIFACT_INTAKE_REPOSITORY_PORT, type ArtifactIntakeRepositoryPortV1, } from './application/artifact-intake-repository.port.js'; +import { ARTIFACT_REPOSITORY_PORT, type ArtifactRepositoryPortV1 } from './application/artifact-repository.port.js'; +import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1 } from './application/evidence-grant-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -14,6 +19,8 @@ import { export interface IaeModuleOptions { readonly artifactIntakeRepository?: ArtifactIntakeRepositoryPortV1; + readonly artifactRepository?: ArtifactRepositoryPortV1; + readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -22,18 +29,26 @@ export class IaeModule { public static register(options: IaeModuleOptions = {}): DynamicModule { return { module: IaeModule, - controllers: [InboxController], + controllers: [InboxController, EvidenceGrantController], providers: [ { provide: ARTIFACT_INTAKE_REPOSITORY_PORT, useValue: options.artifactIntakeRepository ?? new InMemoryArtifactIntakeRepositoryAdapter(), }, + { + provide: ARTIFACT_REPOSITORY_PORT, + useValue: options.artifactRepository ?? new InMemoryArtifactRepositoryAdapter(), + }, + { + provide: EVIDENCE_GRANT_REPOSITORY_PORT, + useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [ARTIFACT_INTAKE_REPOSITORY_PORT], + exports: [ARTIFACT_INTAKE_REPOSITORY_PORT, ARTIFACT_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT], }; } } diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index d68d9893..420bace2 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -63,7 +63,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.deepEqual(paths, [ '/health/live', '/health/ready', + '/v1/artifacts/evidence-grants/{grantId}', '/v1/artifacts/inbox', + '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/auth/sign-in', '/v1/datasets', '/v1/datasets/{datasetId}/mappings', From 75d1060ec0a15c9b25a672bfa3da86445f48de57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:19:56 +0700 Subject: [PATCH 23/44] feat(storage): persist evidence grant lifecycle records --- .../migration.sql | 21 ++++++++++++++++ services/api/prisma/schema/iae.prisma | 24 +++++++++++++++++++ services/api/test/prisma-foundation.test.mjs | 7 ++++++ 3 files changed, 52 insertions(+) create mode 100644 services/api/prisma/migrations/20260802120000_iae_evidence_grants/migration.sql diff --git a/services/api/prisma/migrations/20260802120000_iae_evidence_grants/migration.sql b/services/api/prisma/migrations/20260802120000_iae_evidence_grants/migration.sql new file mode 100644 index 00000000..b4c95676 --- /dev/null +++ b/services/api/prisma/migrations/20260802120000_iae_evidence_grants/migration.sql @@ -0,0 +1,21 @@ +-- IAE-005/IAE-006: expiring device-bound evidence grants. +CREATE TABLE "iae"."evidence_grants" ( + "id" UUID NOT NULL, + "evidence_id" UUID NOT NULL, + "artifact_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "recipient_device_id" UUID NOT NULL, + "action" VARCHAR(24) NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "authorization_epoch" INTEGER NOT NULL, + "max_excerpt_bytes" INTEGER NOT NULL, + "revoked_at" TIMESTAMPTZ(6), + CONSTRAINT "evidence_grants_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "evidence_grants_evidence_idx" ON "iae"."evidence_grants"("evidence_id"); +CREATE INDEX "evidence_grants_artifact_version_idx" ON "iae"."evidence_grants"("artifact_version_id"); +CREATE INDEX "evidence_grants_scope_device_idx" ON "iae"."evidence_grants"("organization_id", "workspace_id", "project_id", "recipient_device_id"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 4e19043e..0cced72a 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -103,3 +103,27 @@ model EvidenceReference { @@map("evidence_references") @@schema("iae") } + +/// IAE-005, IAE-006: short-lived, revocable, device-bound evidence grants. +model EvidenceGrantRecord { + id String @id @db.Uuid + evidenceId String @map("evidence_id") @db.Uuid + artifactVersionId String @map("artifact_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + recipientDeviceId String @map("recipient_device_id") @db.Uuid + action String @db.VarChar(24) + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + authorizationEpoch Int @map("authorization_epoch") + maxExcerptBytes Int @map("max_excerpt_bytes") + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + + @@index([evidenceId], map: "evidence_grants_evidence_idx") + @@index([artifactVersionId], map: "evidence_grants_artifact_version_idx") + @@index([organizationId, workspaceId, projectId, recipientDeviceId], map: "evidence_grants_scope_device_idx") + @@map("evidence_grants") + @@schema("iae") +} diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 0bf85ee5..be8ea179 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -55,6 +55,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_versions"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."inbox_items"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_lineage"/); + assert.match(diff.stdout, /CREATE TABLE "iae"\."evidence_grants"/); assert.match(diff.stdout, /CREATE TABLE "aud"\."audit_events"/); assert.match(diff.stdout, /CREATE TABLE "bua"\."usage_ledger_entries"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_definitions"/); @@ -85,6 +86,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802090000_jra_recipes', '20260802100000_iae_dsm_governance', '20260802110000_dsm_mappings_rules', + '20260802120000_iae_evidence_grants', 'migration_lock.toml', ]); const migration = await readFile( @@ -243,4 +245,9 @@ test('the schema diff and centrally ordered migration inventory establish platfo ]) { assert.match(mappingRulesMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } + const evidenceGrantsMigration = await readFile( + path.join(migrationsDirectory, inventory[13], 'migration.sql'), + 'utf8', + ); + assert.match(evidenceGrantsMigration, /CREATE TABLE "iae"\."evidence_grants"/); }); From b25312b704007355111d550567a412d2f7ef607b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:24:57 +0700 Subject: [PATCH 24/44] feat(api): list scoped artifact intake items --- services/api/openapi/v1.json | 66 +++++++++++++++++++ ...mory-artifact-intake-repository.adapter.ts | 9 +++ .../src/features/iae/api/inbox.controller.ts | 9 ++- .../artifact-intake-repository.port.ts | 1 + .../application/artifact-intake.service.ts | 4 ++ .../iae/artifact-intake.service.test.ts | 9 +++ 6 files changed, 97 insertions(+), 1 deletion(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index c8b78ac4..ad1a717b 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -544,6 +544,72 @@ "security": [{ "bearer": [] }], "summary": "Register a content-free artifact intake item", "tags": ["artifacts"] + }, + "get": { + "operationId": "InboxController.list", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List content-free artifact intake items visible to the caller", + "tags": ["artifacts"] } }, "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts index e1718c74..00120d55 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts @@ -76,6 +76,14 @@ export class InMemoryArtifactIntakeRepositoryAdapter implements ArtifactIntakeRe return item && visible(context.tenantScope, item.tenantScope) ? clone(item) : undefined; } + public async list(context: IamTenantContextV1): Promise { + await Promise.resolve(); + return [...this.items.values()] + .filter((item) => visible(context.tenantScope, item.tenantScope)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .map(clone); + } + public async withTransaction( context: IamTenantContextV1, work: (transaction: ArtifactIntakeTransactionPortV1) => Promise, @@ -92,6 +100,7 @@ export class InMemoryArtifactIntakeRepositoryAdapter implements ArtifactIntakeRe save: this.save.bind(this), findByIdempotency: this.findByIdempotency.bind(this), find: this.find.bind(this), + list: this.list.bind(this), }); } catch (error) { this.items = before; diff --git a/services/api/src/features/iae/api/inbox.controller.ts b/services/api/src/features/iae/api/inbox.controller.ts index 3d619dc5..0eb7f45f 100644 --- a/services/api/src/features/iae/api/inbox.controller.ts +++ b/services/api/src/features/iae/api/inbox.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Headers, Inject, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, Headers, Inject, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { @@ -47,4 +47,11 @@ export class InboxController { createdAt: input.createdAt, }); } + + @Get('inbox') + @ApiOperation({ summary: 'List content-free artifact intake items visible to the caller' }) + async list(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.intake.list(context); + } } diff --git a/services/api/src/features/iae/application/artifact-intake-repository.port.ts b/services/api/src/features/iae/application/artifact-intake-repository.port.ts index 88455bb4..3d9591dc 100644 --- a/services/api/src/features/iae/application/artifact-intake-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-intake-repository.port.ts @@ -11,6 +11,7 @@ export interface ArtifactIntakeTransactionPortV1 { idempotencyKey: string, ): Promise; find(context: IamTenantContextV1, inboxItemId: InboxItemV1['inboxItemId']): Promise; + list(context: IamTenantContextV1): Promise; } export interface ArtifactIntakeRepositoryPortV1 extends ArtifactIntakeTransactionPortV1 { diff --git a/services/api/src/features/iae/application/artifact-intake.service.ts b/services/api/src/features/iae/application/artifact-intake.service.ts index 628fe552..d4b34e89 100644 --- a/services/api/src/features/iae/application/artifact-intake.service.ts +++ b/services/api/src/features/iae/application/artifact-intake.service.ts @@ -65,4 +65,8 @@ export class ArtifactIntakeService { }); }); } + + public async list(context: IamTenantContextV1): Promise { + return this.repository.withTransaction(context, (transaction) => transaction.list(context)); + } } diff --git a/services/api/test/features/iae/artifact-intake.service.test.ts b/services/api/test/features/iae/artifact-intake.service.test.ts index 0f1fefd7..ac71af70 100644 --- a/services/api/test/features/iae/artifact-intake.service.test.ts +++ b/services/api/test/features/iae/artifact-intake.service.test.ts @@ -82,3 +82,12 @@ void test('[IAE-009, IAE-010, IAM-009] admission moves clean content to routed a }); assert.deepEqual(sibling, { accepted: false, code: 'INBOX_NOT_FOUND' }); }); + +void test('[IAE-001, IAM-009] inbox listing is scoped and newest-first', async () => { + const service = new ArtifactIntakeService(new InMemoryArtifactIntakeRepositoryAdapter()); + await service.create(context(workspaceId, 'list-1'), inbox); + await service.create(context(workspaceId, 'list-2'), { ...inbox, inboxItemId: '00000000-0000-4000-8000-000000000024', artifactVersionId: '00000000-0000-4000-8000-000000000025', createdAt: '2026-01-02T00:00:00.000Z', idempotencyKey: 'list-2' }); + await service.create(context(siblingWorkspaceId, 'list-3'), { ...inbox, inboxItemId: '00000000-0000-4000-8000-000000000026', artifactVersionId: '00000000-0000-4000-8000-000000000027', tenantScope: { scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }, createdAt: '2026-01-03T00:00:00.000Z', idempotencyKey: 'list-3' }); + const listed = await service.list(context(workspaceId, 'list-read')); + assert.deepEqual(listed.map((item) => item.inboxItemId), ['00000000-0000-4000-8000-000000000024', inbox.inboxItemId]); +}); From 3b2c4d6109f3c11cbf51f4175f01861e96af4ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:29:50 +0700 Subject: [PATCH 25/44] feat(web): add governed artifact inbox --- apps/web/src/app/messages.ts | 34 ++++++ apps/web/src/app/router.tsx | 8 +- apps/web/src/features/inbox/inbox-api.ts | 68 +++++++++++ apps/web/src/features/inbox/inbox-page.tsx | 131 +++++++++++++++++++++ apps/web/src/styles.css | 15 ++- apps/web/test/inbox-page.test.tsx | 47 ++++++++ 6 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/features/inbox/inbox-api.ts create mode 100644 apps/web/src/features/inbox/inbox-page.tsx create mode 100644 apps/web/test/inbox-page.test.tsx diff --git a/apps/web/src/app/messages.ts b/apps/web/src/app/messages.ts index 646c63ae..5fd6aa08 100644 --- a/apps/web/src/app/messages.ts +++ b/apps/web/src/app/messages.ts @@ -29,6 +29,23 @@ const vietnameseMessages = { 'home.status.approval': 'Chờ phê duyệt', 'home.status.device': 'Cần xác nhận', 'home.status.review': 'Cần xem xét', + 'inbox.heading': 'Hộp thư đến dữ liệu', + 'inbox.caption': 'Các mục tiếp nhận được máy chủ quản trị trong không gian hiện tại.', + 'inbox.loading': 'Đang tải danh sách tiếp nhận…', + 'inbox.error': 'Không thể tải Hộp thư đến. Không có thay đổi nào được gửi.', + 'inbox.retry': 'Tải lại an toàn', + 'inbox.empty': 'Chưa có mục tiếp nhận nào.', + 'inbox.column.item': 'Mục tiếp nhận', + 'inbox.column.state': 'Trạng thái', + 'inbox.column.created': 'Đã tạo', + 'inbox.column.version': 'Phiên bản hiện vật', + 'inbox.state.new': 'Mới', + 'inbox.state.routed': 'Đã định tuyến', + 'inbox.state.needsReview': 'Cần xem xét', + 'inbox.state.processing': 'Đang xử lý', + 'inbox.state.resolved': 'Đã xử lý', + 'inbox.state.quarantined': 'Cách ly', + 'inbox.state.archived': 'Đã lưu trữ', 'locale.english': 'English', 'locale.vietnamese': 'Tiếng Việt', 'nav.administration': 'Quản trị', @@ -81,6 +98,23 @@ const englishMessages: Readonly> = { 'home.status.approval': 'Awaiting approval', 'home.status.device': 'Confirmation needed', 'home.status.review': 'Needs review', + 'inbox.heading': 'Data Inbox', + 'inbox.caption': 'Governed intake items visible in the current workspace.', + 'inbox.loading': 'Loading governed intake…', + 'inbox.error': 'The Inbox could not load. No changes were sent.', + 'inbox.retry': 'Retry safely', + 'inbox.empty': 'No intake items yet.', + 'inbox.column.item': 'Intake item', + 'inbox.column.state': 'State', + 'inbox.column.created': 'Created', + 'inbox.column.version': 'Artifact version', + 'inbox.state.new': 'New', + 'inbox.state.routed': 'Routed', + 'inbox.state.needsReview': 'Needs review', + 'inbox.state.processing': 'Processing', + 'inbox.state.resolved': 'Resolved', + 'inbox.state.quarantined': 'Quarantined', + 'inbox.state.archived': 'Archived', 'locale.english': 'English', 'locale.vietnamese': 'Tiếng Việt', 'nav.administration': 'Administration', diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 97640a26..663ef813 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -15,6 +15,7 @@ import { UnavailableFeature, } from '../pages/shell-states.tsx'; import { WorkspaceHome } from '../pages/workspace-home.tsx'; +import { InboxPage } from '../features/inbox/inbox-page.tsx'; import { WEB_FEATURE_REGISTRY } from './feature-registry.ts'; import { DEFAULT_ACCESS_CONTEXT, type WebAccessContext } from './navigation.ts'; @@ -55,7 +56,12 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] { { path: 'workspace', element: }, ...WEB_FEATURE_REGISTRY.filter((feature) => feature.key !== 'workspace').map((feature) => ({ path: feature.path, - element: , + element: + feature.key === 'inbox' ? ( + + ) : ( + + ), })), { path: 'debug/route-error', element: }, { path: '*', element: }, diff --git a/apps/web/src/features/inbox/inbox-api.ts b/apps/web/src/features/inbox/inbox-api.ts new file mode 100644 index 00000000..14bada48 --- /dev/null +++ b/apps/web/src/features/inbox/inbox-api.ts @@ -0,0 +1,68 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +const inboxStates = [ + 'NEW', + 'ROUTED', + 'NEEDS_REVIEW', + 'PROCESSING', + 'RESOLVED', + 'QUARANTINED', + 'ARCHIVED', +] as const; + +export type InboxState = (typeof inboxStates)[number]; + +export interface InboxListItem { + readonly inboxItemId: string; + readonly artifactVersionId: string; + readonly state: InboxState; + readonly createdAt: string; + readonly revision: number; +} + +function apiBaseUrl(): string { + const configured = import.meta.env['VITE_DATABREEZE_API_BASE_URL']; + if (typeof configured !== 'string' || configured.trim() === '') return ''; + return configured.replace(/\/$/u, ''); +} + +function parseInboxItem(input: unknown): InboxListItem | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const item = input as Record; + const inboxItemId = parseStableIdentifierV1(item['inboxItemId']); + const artifactVersionId = parseStableIdentifierV1(item['artifactVersionId']); + const createdAt = parseStrictUtcTimestampV1(item['createdAt']); + if (!inboxItemId.accepted || !artifactVersionId.accepted || !createdAt.accepted) return undefined; + if (!inboxStates.includes(item['state'] as InboxState)) return undefined; + if ( + typeof item['revision'] !== 'number' || + !Number.isSafeInteger(item['revision']) || + item['revision'] < 1 + ) + return undefined; + return Object.freeze({ + inboxItemId: inboxItemId.value, + artifactVersionId: artifactVersionId.value, + state: item['state'] as InboxState, + createdAt: createdAt.value, + revision: item['revision'], + }); +} + +export async function listInbox(signal?: AbortSignal): Promise { + const requestInit: RequestInit = { + headers: { Accept: 'application/json' }, + credentials: 'include', + }; + if (signal !== undefined) requestInit.signal = signal; + const response = await fetch(`${apiBaseUrl()}/v1/artifacts/inbox`, requestInit); + if (!response.ok) throw new Error('INBOX_REQUEST_FAILED'); + const payload: unknown = await response.json(); + if (!Array.isArray(payload)) throw new Error('INBOX_RESPONSE_INVALID'); + const parsed = payload.map(parseInboxItem); + if (parsed.some((item) => item === undefined)) throw new Error('INBOX_RESPONSE_INVALID'); + return Object.freeze(parsed as InboxListItem[]); +} diff --git a/apps/web/src/features/inbox/inbox-page.tsx b/apps/web/src/features/inbox/inbox-page.tsx new file mode 100644 index 00000000..e2cb58b9 --- /dev/null +++ b/apps/web/src/features/inbox/inbox-page.tsx @@ -0,0 +1,131 @@ +import { Button, Status } from '@databreeze/ui/v1'; +import { useQuery } from '@tanstack/react-query'; +import { appMessage } from '../../app/messages.ts'; +import { useLocale } from '../../app/locale-context.tsx'; +import { listInbox } from './inbox-api.ts'; + +function stateMessageKey( + state: string, +): + | 'inbox.state.new' + | 'inbox.state.routed' + | 'inbox.state.needsReview' + | 'inbox.state.processing' + | 'inbox.state.resolved' + | 'inbox.state.quarantined' + | 'inbox.state.archived' { + const keys = { + NEW: 'inbox.state.new', + ROUTED: 'inbox.state.routed', + NEEDS_REVIEW: 'inbox.state.needsReview', + PROCESSING: 'inbox.state.processing', + RESOLVED: 'inbox.state.resolved', + QUARANTINED: 'inbox.state.quarantined', + ARCHIVED: 'inbox.state.archived', + } as const; + return keys[state as keyof typeof keys] ?? keys.NEW; +} + +function stateKind(state: string): 'danger' | 'info' | 'success' | 'warning' { + if (state === 'RESOLVED' || state === 'ARCHIVED') return 'success'; + if (state === 'QUARANTINED') return 'danger'; + if (state === 'PROCESSING') return 'info'; + return 'warning'; +} + +export function InboxPage() { + const locale = useLocale(); + const query = useQuery({ + queryKey: ['artifacts', 'inbox'], + queryFn: ({ signal }) => listInbox(signal), + retry: false, + }); + + if (query.isPending) { + return ( +
+
+
+

{appMessage(locale, 'inbox.heading')}

+

{appMessage(locale, 'inbox.caption')}

+
+
+ {appMessage(locale, 'inbox.loading')} +
+ ); + } + + if (query.isError) { + return ( +
+
+
+

{appMessage(locale, 'inbox.heading')}

+

{appMessage(locale, 'inbox.caption')}

+
+
+ {appMessage(locale, 'inbox.error')} + +
+ ); + } + + const items = query.data; + return ( +
+
+
+

{appMessage(locale, 'inbox.heading')}

+

{appMessage(locale, 'inbox.caption')}

+
+ + {items.length === 0 ? appMessage(locale, 'inbox.empty') : `${items.length}`} + +
+ {items.length === 0 ? ( +

{appMessage(locale, 'inbox.empty')}

+ ) : ( +
+ + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
{appMessage(locale, 'inbox.column.item')}{appMessage(locale, 'inbox.column.state')}{appMessage(locale, 'inbox.column.created')}{appMessage(locale, 'inbox.column.version')}
+ {item.inboxItemId} + + + {appMessage(locale, stateMessageKey(item.state))} + + + + + {item.artifactVersionId} +
+
+ )} +

{appMessage(locale, 'access.clientHint')}

+
+ ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f9136230..18a5caca 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -217,10 +217,23 @@ a { outline-offset: calc(-1 * var(--db-focus-ring-width)); } .work-surface, -.feature-placeholder { +.feature-placeholder, +.feature-surface { max-width: 1120px; margin-inline: auto; } +.feature-surface code { + color: var(--db-color-text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--db-typography-font-size-caption); +} +.inbox-retry { + margin-block-start: var(--db-spacing-4); +} +.inbox-empty { + padding-block: var(--db-spacing-8); + color: var(--db-color-text-muted); +} .work-surface__heading { display: flex; justify-content: space-between; diff --git a/apps/web/test/inbox-page.test.tsx b/apps/web/test/inbox-page.test.tsx new file mode 100644 index 00000000..721646b0 --- /dev/null +++ b/apps/web/test/inbox-page.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx'; + +const inboxItem = { + schemaVersion: 1, + inboxItemId: '00000000-0000-4000-8000-000000000001', + artifactVersionId: '00000000-0000-4000-8000-000000000002', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000003', + workspaceId: '00000000-0000-4000-8000-000000000004', + }, + idempotencyKey: 'safe-fixture', + state: 'NEEDS_REVIEW', + createdAt: '2026-01-02T00:00:00.000Z', + revision: 1, +}; + +describe('governed artifact inbox', () => { + it('renders server-owned intake state without exposing scope or idempotency data', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify([inboxItem]), { status: 200 })), + ); + const router = createAppRouter({ initialEntries: ['/en/inbox'] }); + render(); + + expect(await screen.findByRole('heading', { name: 'Data Inbox' })).toBeTruthy(); + expect(await screen.findByText('Needs review')).toBeTruthy(); + expect(screen.getByText(inboxItem.inboxItemId)).toBeTruthy(); + expect(screen.queryByText('safe-fixture')).toBeNull(); + expect(screen.queryByText(inboxItem.tenantScope.organizationId)).toBeNull(); + }); + + it('shows a safe retry state when the API is unavailable', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('private provider detail')); + vi.stubGlobal('fetch', fetchMock); + const router = createAppRouter({ initialEntries: ['/en/inbox'] }); + render(); + + expect(await screen.findByText('The Inbox could not load. No changes were sent.')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Retry safely' })).toBeTruthy(); + expect(screen.queryByText(/private provider detail/u)).toBeNull(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + }); +}); From c1f6182e55051dd8cb4cde5c193af59112569f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:34:19 +0700 Subject: [PATCH 26/44] feat(api): add durable artifact intake repository port --- ...isma-artifact-intake-repository.adapter.ts | 240 ++++++++++++++++++ .../prisma-artifact-intake-repository.test.ts | 144 +++++++++++ 2 files changed, 384 insertions(+) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-intake-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts new file mode 100644 index 00000000..817a91ee --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts @@ -0,0 +1,240 @@ +import { + createInboxItemV1, + type InboxItemStateV1, + type InboxItemV1, +} from '@databreeze/domain/artifact-intake/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactIntakeRepositoryPortV1, + ArtifactIntakeTransactionPortV1, +} from '../application/artifact-intake-repository.port.js'; + +/** Minimal row shape keeps the feature independent of generated Prisma output paths. */ +export interface ArtifactIntakeDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly idempotencyKey: string; + readonly artifactVersionId: string; + readonly state: string; + readonly createdAt: Date; + readonly revision: number; +} + +export interface ArtifactIntakeDatabaseCreateDataV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly idempotencyKey: string; + readonly artifactVersionId: string; + readonly state: InboxItemStateV1; + readonly createdAt: Date; + readonly revision: number; +} + +export interface ArtifactIntakeDatabaseDelegateV1 { + create(input: { + readonly data: ArtifactIntakeDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly createdAt: 'desc' }; + }): Promise; +} + +export interface ArtifactIntakeDatabaseClientV1 { + readonly inboxItem: ArtifactIntakeDatabaseDelegateV1; + $transaction( + work: (transaction: ArtifactIntakeDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1): { + readonly scopeType: TenantScopeV1['scopeType']; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; +} { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + }; +} + +function domainScope(row: ArtifactIntakeDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactIntakeDatabaseRowV1): InboxItemV1 { + const created = createInboxItemV1({ + inboxItemId: row.id, + tenantScope: domainScope(row), + idempotencyKey: row.idempotencyKey, + artifactVersionId: row.artifactVersionId, + createdAt: row.createdAt.toISOString(), + }); + if ( + !created.accepted || + ![ + 'NEW', + 'ROUTED', + 'NEEDS_REVIEW', + 'PROCESSING', + 'RESOLVED', + 'QUARANTINED', + 'ARCHIVED', + ].includes(row.state) + ) { + throw new Error('IAE_PERSISTED_INBOX_INVALID'); + } + if (!Number.isSafeInteger(row.revision) || row.revision < 1) { + throw new Error('IAE_PERSISTED_REVISION_INVALID'); + } + return Object.freeze({ + ...created.value, + state: row.state as InboxItemStateV1, + revision: row.revision, + }); +} + +function domainToCreate(item: InboxItemV1): ArtifactIntakeDatabaseCreateDataV1 { + const scope = databaseScope(item.tenantScope); + return { + ...scope, + id: item.inboxItemId, + idempotencyKey: item.idempotencyKey, + artifactVersionId: item.artifactVersionId, + state: item.state, + createdAt: new Date(item.createdAt), + revision: item.revision, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactIntakeDatabaseRowV1): boolean { + const candidate = domainScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function exactScopeWhere(scope: TenantScopeV1): Readonly> { + const database = databaseScope(scope); + return { + organizationId: database.organizationId, + workspaceId: database.workspaceId, + projectId: database.projectId, + }; +} + +class PrismaArtifactIntakeTransactionAdapter implements ArtifactIntakeTransactionPortV1 { + public constructor(private readonly client: ArtifactIntakeDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, item: InboxItemV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, item.tenantScope)) { + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + } + const existing = await this.client.inboxItem.findUnique({ where: { id: item.inboxItemId } }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(item)) { + throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); + } + return; + } + await this.client.inboxItem.create({ data: domainToCreate(item) }); + } + + public async findByIdempotency( + context: IamTenantContextV1, + idempotencyKey: string, + ): Promise { + const row = await this.client.inboxItem.findFirst({ + where: { ...exactScopeWhere(context.tenantScope), idempotencyKey }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async find( + context: IamTenantContextV1, + inboxItemId: InboxItemV1['inboxItemId'], + ): Promise { + const row = await this.client.inboxItem.findUnique({ where: { id: inboxItemId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list(context: IamTenantContextV1): Promise { + const rows = await this.client.inboxItem.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { createdAt: 'desc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaArtifactIntakeRepositoryAdapter implements ArtifactIntakeRepositoryPortV1 { + public constructor(private readonly client: ArtifactIntakeDatabaseClientV1) {} + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactIntakeTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactIntakeTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, item: InboxItemV1): Promise { + return new PrismaArtifactIntakeTransactionAdapter(this.client).save(context, item); + } + + public findByIdempotency( + context: IamTenantContextV1, + idempotencyKey: string, + ): Promise { + return new PrismaArtifactIntakeTransactionAdapter(this.client).findByIdempotency( + context, + idempotencyKey, + ); + } + + public find( + context: IamTenantContextV1, + inboxItemId: InboxItemV1['inboxItemId'], + ): Promise { + return new PrismaArtifactIntakeTransactionAdapter(this.client).find(context, inboxItemId); + } + + public list(context: IamTenantContextV1): Promise { + return new PrismaArtifactIntakeTransactionAdapter(this.client).list(context); + } +} 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 new file mode 100644 index 00000000..b34735c8 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts @@ -0,0 +1,144 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + PrismaArtifactIntakeRepositoryAdapter, + type ArtifactIntakeDatabaseClientV1, + type ArtifactIntakeDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-intake-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { InboxItemV1 } from '@databreeze/domain/artifact-intake/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +function identifier(value: string): StableIdentifierV1 { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture identifier rejected'); + return result.value; +} + +function timestamp(value: string): StrictUtcTimestampV1 { + const result = parseStrictUtcTimestampV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture timestamp rejected'); + return result.value; +} + +const organizationId = identifier('00000000-0000-4000-8000-000000000001'); +const workspaceId = identifier('00000000-0000-4000-8000-000000000002'); +const siblingWorkspaceId = identifier('00000000-0000-4000-8000-000000000003'); +const itemId = identifier('00000000-0000-4000-8000-000000000004'); +const artifactVersionId = identifier('00000000-0000-4000-8000-000000000005'); + +function row(id: string, candidateWorkspaceId: string): ArtifactIntakeDatabaseRowV1 { + return { + id, + scopeType: 'workspace', + organizationId, + workspaceId: candidateWorkspaceId, + projectId: null, + idempotencyKey: id, + artifactVersionId, + state: 'NEW', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + revision: 1, + }; +} + +function client(rows: ArtifactIntakeDatabaseRowV1[]): ArtifactIntakeDatabaseClientV1 { + return { + inboxItem: { + create(input) { + const created = { ...input.data }; + const persisted = { ...created } as ArtifactIntakeDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique(input) { + return Promise.resolve(rows.find((candidate) => candidate.id === input.where.id) ?? null); + }, + findFirst(input) { + return Promise.resolve( + rows.find((candidate) => + Object.entries(input.where).every( + ([key, value]) => candidate[key as keyof ArtifactIntakeDatabaseRowV1] === value, + ), + ) ?? null, + ); + }, + findMany(input) { + return Promise.resolve( + rows + .filter((candidate) => candidate.organizationId === input.where['organizationId']) + .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()), + ); + }, + }, + async $transaction(work) { + return work(this); + }, + }; +} + +function context(candidateWorkspaceId: string, idempotencyKey: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000006', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: candidateWorkspaceId }, + authorizationEpoch: 3, + correlationId: '00000000-0000-4000-8000-000000000007', + idempotencyKey, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-001, IAM-009] Prisma adapter maps rows, lists newest first, and hides sibling workspaces', async () => { + const rows = [ + row(itemId, workspaceId), + { + ...row('00000000-0000-4000-8000-000000000008', siblingWorkspaceId), + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ]; + const repository = new PrismaArtifactIntakeRepositoryAdapter(client(rows)); + const listed = await repository.list(context(workspaceId, 'list')); + assert.deepEqual( + listed.map((item) => item.inboxItemId), + [itemId], + ); + assert.equal( + (await repository.find(context(workspaceId, 'find'), itemId))?.artifactVersionId, + artifactVersionId, + ); +}); + +void test('[IAE-001] Prisma adapter uses immutable idempotent writes', async () => { + const rows: ArtifactIntakeDatabaseRowV1[] = []; + const repository = new PrismaArtifactIntakeRepositoryAdapter(client(rows)); + const item: InboxItemV1 = { + schemaVersion: 1 as const, + inboxItemId: itemId, + tenantScope: { scopeType: 'workspace' as const, organizationId, workspaceId }, + idempotencyKey: 'same', + artifactVersionId, + state: 'NEW' as const, + createdAt: timestamp('2026-01-01T00:00:00.000Z'), + revision: 1, + }; + await repository.save(context(workspaceId, 'save'), item); + await repository.save(context(workspaceId, 'save-replay'), item); + assert.equal(rows.length, 1); + await assert.rejects( + repository.save(context(workspaceId, 'save-conflict'), { + ...item, + artifactVersionId: identifier('00000000-0000-4000-8000-000000000009'), + }), + /IAE_IMMUTABLE_INBOX_ITEM/u, + ); +}); From e7d62c9929fe706edee7ad497433757926b2144e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:35:57 +0700 Subject: [PATCH 27/44] fix(storage): enforce tenant scope uniqueness --- .../migration.sql | 56 +++++++++++++++++++ services/api/test/prisma-foundation.test.mjs | 22 +++++++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260802130000_iae_dsm_scope_hardening/migration.sql diff --git a/services/api/prisma/migrations/20260802130000_iae_dsm_scope_hardening/migration.sql b/services/api/prisma/migrations/20260802130000_iae_dsm_scope_hardening/migration.sql new file mode 100644 index 00000000..25e49f8b --- /dev/null +++ b/services/api/prisma/migrations/20260802130000_iae_dsm_scope_hardening/migration.sql @@ -0,0 +1,56 @@ +-- Harden nullable-scope uniqueness and complete tenant ancestry added after the +-- initial governance tables. PostgreSQL NULLs do not participate in a normal +-- unique index, so each scope level gets an explicit partial unique index. +UPDATE "iae"."artifact_lineage" AS lineage +SET + "scope_type" = versions."scope_type", + "organization_id" = versions."organization_id", + "workspace_id" = versions."workspace_id", + "project_id" = versions."project_id" +FROM "iae"."artifact_versions" AS versions +WHERE lineage."derived_artifact_version_id" = versions."id" + AND lineage."organization_id" IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "iae"."artifact_lineage" WHERE "organization_id" IS NULL + ) THEN + RAISE EXCEPTION 'artifact lineage rows must resolve to an artifact tenant'; + END IF; +END $$; + +ALTER TABLE "iae"."artifact_lineage" + ALTER COLUMN "organization_id" SET NOT NULL; + +UPDATE "dsm"."reference_entity_resolutions" AS resolutions +SET + "scope_type" = versions."scope_type", + "organization_id" = versions."organization_id", + "workspace_id" = versions."workspace_id", + "project_id" = versions."project_id" +FROM "dsm"."reference_entity_versions" AS versions +WHERE resolutions."source_entity_id" = versions."entity_id" + AND resolutions."organization_id" IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "dsm"."reference_entity_resolutions" WHERE "organization_id" IS NULL + ) THEN + RAISE EXCEPTION 'reference entity resolution rows must resolve to an entity tenant'; + END IF; +END $$; + +ALTER TABLE "dsm"."reference_entity_resolutions" + ALTER COLUMN "organization_id" SET NOT NULL; + +CREATE UNIQUE INDEX "inbox_items_organization_idempotency_key" + ON "iae"."inbox_items"("organization_id", "idempotency_key") + WHERE "scope_type" = 'organization' AND "workspace_id" IS NULL AND "project_id" IS NULL; +CREATE UNIQUE INDEX "inbox_items_workspace_idempotency_key" + ON "iae"."inbox_items"("organization_id", "workspace_id", "idempotency_key") + WHERE "scope_type" = 'workspace' AND "workspace_id" IS NOT NULL AND "project_id" IS NULL; +CREATE UNIQUE INDEX "inbox_items_project_idempotency_key" + ON "iae"."inbox_items"("organization_id", "workspace_id", "project_id", "idempotency_key") + WHERE "scope_type" = 'project' AND "workspace_id" IS NOT NULL AND "project_id" IS NOT NULL; diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index be8ea179..78b70a0c 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -87,6 +87,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802100000_iae_dsm_governance', '20260802110000_dsm_mappings_rules', '20260802120000_iae_evidence_grants', + '20260802130000_iae_dsm_scope_hardening', 'migration_lock.toml', ]); const migration = await readFile( @@ -243,11 +244,30 @@ test('the schema diff and centrally ordered migration inventory establish platfo 'CREATE TABLE "dsm"."rule_set_definitions"', 'CREATE INDEX "artifact_lineage_scope_idx"', ]) { - assert.match(mappingRulesMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match( + mappingRulesMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); } const evidenceGrantsMigration = await readFile( path.join(migrationsDirectory, inventory[13], 'migration.sql'), 'utf8', ); assert.match(evidenceGrantsMigration, /CREATE TABLE "iae"\."evidence_grants"/); + const scopeHardeningMigration = await readFile( + path.join(migrationsDirectory, inventory[14], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'ALTER TABLE "iae"."artifact_lineage"', + 'ALTER TABLE "dsm"."reference_entity_resolutions"', + 'CREATE UNIQUE INDEX "inbox_items_organization_idempotency_key"', + 'CREATE UNIQUE INDEX "inbox_items_workspace_idempotency_key"', + 'CREATE UNIQUE INDEX "inbox_items_project_idempotency_key"', + ]) { + assert.match( + scopeHardeningMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 6f4801fa6239b37330616e720c74991523ecb0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:36:49 +0700 Subject: [PATCH 28/44] feat(api): wire Prisma intake composition --- services/api/src/features/iae/iae.module.ts | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 5ea7f509..bfc87f4a 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -3,14 +3,24 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { InboxController } from './api/inbox.controller.js'; import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; +import { + PrismaArtifactIntakeRepositoryAdapter, + type ArtifactIntakeDatabaseClientV1, +} from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryEvidenceGrantRepositoryAdapter } from './adapter/in-memory-evidence-grant-repository.adapter.js'; import { ARTIFACT_INTAKE_REPOSITORY_PORT, type ArtifactIntakeRepositoryPortV1, } from './application/artifact-intake-repository.port.js'; -import { ARTIFACT_REPOSITORY_PORT, type ArtifactRepositoryPortV1 } from './application/artifact-repository.port.js'; -import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1 } from './application/evidence-grant-repository.port.js'; +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from './application/artifact-repository.port.js'; +import { + EVIDENCE_GRANT_REPOSITORY_PORT, + type EvidenceGrantRepositoryPortV1, +} from './application/evidence-grant-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -19,6 +29,8 @@ import { export interface IaeModuleOptions { readonly artifactIntakeRepository?: ArtifactIntakeRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactIntakeDatabase?: ArtifactIntakeDatabaseClientV1; readonly artifactRepository?: ArtifactRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; @@ -33,7 +45,11 @@ export class IaeModule { providers: [ { provide: ARTIFACT_INTAKE_REPOSITORY_PORT, - useValue: options.artifactIntakeRepository ?? new InMemoryArtifactIntakeRepositoryAdapter(), + useValue: + options.artifactIntakeRepository ?? + (options.artifactIntakeDatabase === undefined + ? new InMemoryArtifactIntakeRepositoryAdapter() + : new PrismaArtifactIntakeRepositoryAdapter(options.artifactIntakeDatabase)), }, { provide: ARTIFACT_REPOSITORY_PORT, @@ -48,7 +64,11 @@ export class IaeModule { useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [ARTIFACT_INTAKE_REPOSITORY_PORT, ARTIFACT_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT], + exports: [ + ARTIFACT_INTAKE_REPOSITORY_PORT, + ARTIFACT_REPOSITORY_PORT, + EVIDENCE_GRANT_REPOSITORY_PORT, + ], }; } } From ec508a303cd9ecf3f263c6ddbbdea6d378aa9ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:40:11 +0700 Subject: [PATCH 29/44] feat(api): add durable dataset definition repository --- ...sma-governed-dataset-repository.adapter.ts | 205 ++++++++++++++++++ ...prisma-governed-dataset-repository.test.ts | 155 +++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts diff --git a/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts new file mode 100644 index 00000000..3bd2c6d7 --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts @@ -0,0 +1,205 @@ +import { + createGovernedDatasetDefinitionV1, + type GovernedDatasetDefinitionV1, +} from '@databreeze/domain/dataset-governance/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + GovernedDatasetRepositoryPortV1, + GovernedDatasetTransactionPortV1, +} from '../application/governed-dataset-repository.port.js'; + +export interface GovernedDatasetDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly schemaVersion: number; + readonly name: string; + readonly fields: unknown; + readonly status: string; + readonly createdAt: Date; + readonly publishedAt: Date | null; + readonly canonicalHash: string; +} + +export interface GovernedDatasetDatabaseCreateDataV1 { + readonly id: string; + readonly datasetId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly schemaVersion: number; + readonly name: string; + readonly fields: unknown; + readonly status: string; + readonly createdAt: Date; + readonly publishedAt: Date | null; + readonly revision: number; + readonly canonicalHash: string; +} + +export interface GovernedDatasetDatabaseDelegateV1 { + create(input: { + readonly data: GovernedDatasetDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly createdAt: 'asc' }; + }): Promise; +} + +export interface GovernedDatasetDatabaseClientV1 { + readonly datasetDefinitionRecord: GovernedDatasetDatabaseDelegateV1; + $transaction( + work: (transaction: GovernedDatasetDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function domainScope(row: GovernedDatasetDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: GovernedDatasetDatabaseRowV1): GovernedDatasetDefinitionV1 { + const created = createGovernedDatasetDefinitionV1({ + datasetId: row.datasetId, + versionId: row.id, + tenantScope: domainScope(row), + name: row.name, + fields: row.fields, + status: row.status, + createdAt: row.createdAt.toISOString(), + ...(row.publishedAt === null ? {} : { publishedAt: row.publishedAt.toISOString() }), + canonicalHash: row.canonicalHash, + }); + if (!created.accepted) throw new Error('DSM_PERSISTED_DEFINITION_INVALID'); + return created.value; +} + +function domainToCreate( + definition: GovernedDatasetDefinitionV1, +): GovernedDatasetDatabaseCreateDataV1 { + const scope = databaseScope(definition.tenantScope); + return { + ...scope, + id: definition.versionId, + datasetId: definition.datasetId, + schemaVersion: definition.schemaVersion, + name: definition.name, + fields: definition.fields, + status: definition.status, + createdAt: new Date(definition.createdAt), + publishedAt: definition.publishedAt === undefined ? null : new Date(definition.publishedAt), + revision: 1, + canonicalHash: definition.canonicalHash, + }; +} + +function visible(context: TenantScopeV1, row: GovernedDatasetDatabaseRowV1): boolean { + const candidate = domainScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaGovernedDatasetTransactionAdapter implements GovernedDatasetTransactionPortV1 { + public constructor(private readonly client: GovernedDatasetDatabaseClientV1) {} + + public async save( + context: IamTenantContextV1, + definition: GovernedDatasetDefinitionV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) { + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + } + const existing = await this.client.datasetDefinitionRecord.findUnique({ + where: { id: definition.versionId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(definition)) { + throw new Error('DSM_IMMUTABLE_DEFINITION'); + } + return; + } + await this.client.datasetDefinitionRecord.create({ data: domainToCreate(definition) }); + } + + public async find( + context: IamTenantContextV1, + versionId: GovernedDatasetDefinitionV1['versionId'], + ): Promise { + const row = await this.client.datasetDefinitionRecord.findUnique({ where: { id: versionId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetId: GovernedDatasetDefinitionV1['datasetId'], + ): Promise { + const rows = await this.client.datasetDefinitionRecord.findMany({ + where: { datasetId, organizationId: context.tenantScope.organizationId }, + orderBy: { createdAt: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaGovernedDatasetRepositoryAdapter implements GovernedDatasetRepositoryPortV1 { + public constructor(private readonly client: GovernedDatasetDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: GovernedDatasetTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaGovernedDatasetTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, definition: GovernedDatasetDefinitionV1): Promise { + return new PrismaGovernedDatasetTransactionAdapter(this.client).save(context, definition); + } + + public find( + context: IamTenantContextV1, + versionId: GovernedDatasetDefinitionV1['versionId'], + ): Promise { + return new PrismaGovernedDatasetTransactionAdapter(this.client).find(context, versionId); + } + + public list( + context: IamTenantContextV1, + datasetId: GovernedDatasetDefinitionV1['datasetId'], + ): Promise { + return new PrismaGovernedDatasetTransactionAdapter(this.client).list(context, datasetId); + } +} diff --git a/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts b/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts new file mode 100644 index 00000000..89b14f93 --- /dev/null +++ b/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts @@ -0,0 +1,155 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { GovernedDatasetDefinitionV1 } from '@databreeze/domain/dataset-governance/v1'; +import { + PrismaGovernedDatasetRepositoryAdapter, + type GovernedDatasetDatabaseClientV1, + type GovernedDatasetDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function identifier(value: string): StableIdentifierV1 { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture identifier rejected'); + return result.value; +} + +const organizationId = identifier('00000000-0000-4000-8000-000000000101'); +const workspaceId = identifier('00000000-0000-4000-8000-000000000102'); +const siblingWorkspaceId = identifier('00000000-0000-4000-8000-000000000103'); +const datasetId = identifier('00000000-0000-4000-8000-000000000104'); +const versionId = identifier('00000000-0000-4000-8000-000000000105'); +const fieldId = identifier('00000000-0000-4000-8000-000000000106'); + +function context(candidateWorkspaceId: string, idempotencyKey: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000107', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: candidateWorkspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000108', + idempotencyKey, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function row(id: string, candidateWorkspaceId: string): GovernedDatasetDatabaseRowV1 { + return { + id, + datasetId, + scopeType: 'workspace', + organizationId, + workspaceId: candidateWorkspaceId, + projectId: null, + schemaVersion: 1, + name: 'Orders', + fields: [ + { + fieldId, + name: 'Order ID', + type: 'TEXT', + nullable: false, + aliases: [], + localizedLabels: { en: 'Order ID' }, + sensitivity: 'INTERNAL', + defaultBehavior: 'NONE', + }, + ], + status: 'DRAFT', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + publishedAt: null, + canonicalHash: 'a'.repeat(64), + }; +} + +function client(rows: GovernedDatasetDatabaseRowV1[]): GovernedDatasetDatabaseClientV1 { + return { + datasetDefinitionRecord: { + create(input) { + const created = { ...input.data } as GovernedDatasetDatabaseRowV1; + rows.push(created); + return Promise.resolve(created); + }, + findUnique(input) { + return Promise.resolve(rows.find((candidate) => candidate.id === input.where.id) ?? null); + }, + findMany(input) { + return Promise.resolve( + rows + .filter( + (candidate) => + candidate.datasetId === input.where['datasetId'] && + candidate.organizationId === input.where['organizationId'], + ) + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-001, IAM-009] Prisma dataset adapter maps immutable rows and filters sibling workspaces', async () => { + const repository = new PrismaGovernedDatasetRepositoryAdapter( + client([ + row(versionId, workspaceId), + { ...row('00000000-0000-4000-8000-000000000109', siblingWorkspaceId) }, + ]), + ); + const listed = await repository.list(context(workspaceId, 'list'), datasetId); + assert.deepEqual( + listed.map((definition) => definition.versionId), + [versionId], + ); + assert.equal((await repository.find(context(workspaceId, 'find'), versionId))?.name, 'Orders'); +}); + +void test('[DSM-001] Prisma dataset adapter persists a replay exactly once', async () => { + const rows: GovernedDatasetDatabaseRowV1[] = []; + const repository = new PrismaGovernedDatasetRepositoryAdapter(client(rows)); + const dataset = parseStableIdentifierV1(datasetId); + const version = parseStableIdentifierV1(versionId); + const field = parseStableIdentifierV1(fieldId); + const created = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(dataset.accepted, true); + assert.equal(version.accepted, true); + assert.equal(field.accepted, true); + assert.equal(created.accepted, true); + if (!dataset.accepted || !version.accepted || !field.accepted || !created.accepted) + throw new Error('fixture rejected'); + const definition: GovernedDatasetDefinitionV1 = { + schemaVersion: 1, + datasetId: dataset.value, + versionId: version.value, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + name: 'Orders', + fields: [ + { + fieldId: field.value, + name: 'Order ID', + type: 'TEXT', + nullable: false, + aliases: [], + localizedLabels: { en: 'Order ID' }, + sensitivity: 'INTERNAL', + defaultBehavior: 'NONE', + }, + ], + status: 'DRAFT', + createdAt: created.value, + canonicalHash: 'a'.repeat(64), + }; + await repository.save(context(workspaceId, 'save'), definition); + await repository.save(context(workspaceId, 'replay'), definition); + assert.equal(rows.length, 1); +}); From 5decd26e670df2fdf8df36f1688ac73bbbd93c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:41:01 +0700 Subject: [PATCH 30/44] feat(api): wire Prisma dataset composition --- services/api/src/features/dsm/dsm.module.ts | 50 +++++++++++++++++---- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 76bb04e8..e852d155 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -5,6 +5,10 @@ import { MappingController } from './api/mapping.controller.js'; import { ReferenceEntityController } from './api/reference-entity.controller.js'; import { RuleSetController } from './api/rule-set.controller.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; +import { + PrismaGovernedDatasetRepositoryAdapter, + type GovernedDatasetDatabaseClientV1, +} from './adapter/prisma-governed-dataset-repository.adapter.js'; import { InMemoryMappingRepositoryAdapter } from './adapter/in-memory-mapping-repository.adapter.js'; import { InMemoryReferenceEntityRepositoryAdapter } from './adapter/in-memory-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; @@ -12,9 +16,18 @@ import { GOVERNED_DATASET_REPOSITORY_PORT, type GovernedDatasetRepositoryPortV1, } from './application/governed-dataset-repository.port.js'; -import { MAPPING_REPOSITORY_PORT, type MappingRepositoryPortV1 } from './application/mapping-repository.port.js'; -import { REFERENCE_ENTITY_REPOSITORY_PORT, type ReferenceEntityRepositoryPortV1 } from './application/reference-entity-repository.port.js'; -import { RULE_SET_REPOSITORY_PORT, type RuleSetRepositoryPortV1 } from './application/rule-set-repository.port.js'; +import { + MAPPING_REPOSITORY_PORT, + type MappingRepositoryPortV1, +} from './application/mapping-repository.port.js'; +import { + REFERENCE_ENTITY_REPOSITORY_PORT, + type ReferenceEntityRepositoryPortV1, +} from './application/reference-entity-repository.port.js'; +import { + RULE_SET_REPOSITORY_PORT, + type RuleSetRepositoryPortV1, +} from './application/rule-set-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -23,6 +36,8 @@ import { export interface DsmModuleOptions { readonly governedDatasetRepository?: GovernedDatasetRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly governedDatasetDatabase?: GovernedDatasetDatabaseClientV1; readonly mappingRepository?: MappingRepositoryPortV1; readonly ruleSetRepository?: RuleSetRepositoryPortV1; readonly referenceEntityRepository?: ReferenceEntityRepositoryPortV1; @@ -34,15 +49,34 @@ export class DsmModule { public static register(options: DsmModuleOptions = {}): DynamicModule { return { module: DsmModule, - controllers: [GovernedDatasetController, MappingController, RuleSetController, ReferenceEntityController], + controllers: [ + GovernedDatasetController, + MappingController, + RuleSetController, + ReferenceEntityController, + ], providers: [ { provide: GOVERNED_DATASET_REPOSITORY_PORT, - useValue: options.governedDatasetRepository ?? new InMemoryGovernedDatasetRepositoryAdapter(), + useValue: + options.governedDatasetRepository ?? + (options.governedDatasetDatabase === undefined + ? new InMemoryGovernedDatasetRepositoryAdapter() + : new PrismaGovernedDatasetRepositoryAdapter(options.governedDatasetDatabase)), + }, + { + provide: MAPPING_REPOSITORY_PORT, + useValue: options.mappingRepository ?? new InMemoryMappingRepositoryAdapter(), + }, + { + provide: RULE_SET_REPOSITORY_PORT, + useValue: options.ruleSetRepository ?? new InMemoryRuleSetRepositoryAdapter(), + }, + { + provide: REFERENCE_ENTITY_REPOSITORY_PORT, + useValue: + options.referenceEntityRepository ?? new InMemoryReferenceEntityRepositoryAdapter(), }, - { provide: MAPPING_REPOSITORY_PORT, useValue: options.mappingRepository ?? new InMemoryMappingRepositoryAdapter() }, - { provide: RULE_SET_REPOSITORY_PORT, useValue: options.ruleSetRepository ?? new InMemoryRuleSetRepositoryAdapter() }, - { provide: REFERENCE_ENTITY_REPOSITORY_PORT, useValue: options.referenceEntityRepository ?? new InMemoryReferenceEntityRepositoryAdapter() }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), From 876d77f50c85dcf6a912d6eb7836bc0e3069382c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:42:46 +0700 Subject: [PATCH 31/44] feat(api): add durable mapping repository --- .../prisma-mapping-repository.adapter.ts | 180 ++++++++++++++++++ .../dsm/prisma-mapping-repository.test.ts | 94 +++++++++ 2 files changed, 274 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-mapping-repository.test.ts diff --git a/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts new file mode 100644 index 00000000..3b0b7bb3 --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts @@ -0,0 +1,180 @@ +import { createMappingDefinitionV1, type MappingDefinitionV1 } from '@databreeze/domain/mapping/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + MappingRepositoryPortV1, + MappingTransactionPortV1, +} from '../application/mapping-repository.port.js'; + +export interface MappingDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly sourceSchemaVersionId: string; + readonly targetSchemaVersionId: string; + readonly steps: unknown; + readonly status: string; + readonly createdAt: Date; + readonly publishedAt: Date | null; + readonly canonicalHash: string; +} + +export interface MappingDatabaseCreateDataV1 + extends Omit { + readonly steps: unknown; + readonly createdAt: Date; + readonly publishedAt: Date | null; + readonly revision: number; +} + +export interface MappingDatabaseClientV1 { + readonly mappingDefinitionRecord: { + create(input: { readonly data: MappingDatabaseCreateDataV1 }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly createdAt: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: MappingDatabaseClientV1) => Promise, + ): Promise; +} + +function scopeForRow(row: MappingDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowToDomain(row: MappingDatabaseRowV1): MappingDefinitionV1 { + const parsed = createMappingDefinitionV1({ + datasetId: row.datasetId, + versionId: row.id, + tenantScope: scopeForRow(row), + sourceSchemaVersionId: row.sourceSchemaVersionId, + targetSchemaVersionId: row.targetSchemaVersionId, + steps: row.steps, + status: row.status, + createdAt: row.createdAt.toISOString(), + ...(row.publishedAt === null ? {} : { publishedAt: row.publishedAt.toISOString() }), + canonicalHash: row.canonicalHash, + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_MAPPING_INVALID'); + return parsed.value; +} + +function domainToRow(definition: MappingDefinitionV1): MappingDatabaseCreateDataV1 { + return { + ...databaseScope(definition.tenantScope), + id: definition.versionId, + datasetId: definition.datasetId, + sourceSchemaVersionId: definition.sourceSchemaVersionId, + targetSchemaVersionId: definition.targetSchemaVersionId, + steps: definition.steps, + status: definition.status, + createdAt: new Date(definition.createdAt), + publishedAt: definition.publishedAt === undefined ? null : new Date(definition.publishedAt), + revision: 1, + canonicalHash: definition.canonicalHash, + }; +} + +function visible(context: TenantScopeV1, row: MappingDatabaseRowV1): boolean { + const candidate = scopeForRow(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaMappingTransactionAdapter implements MappingTransactionPortV1 { + public constructor(private readonly client: MappingDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, definition: MappingDefinitionV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.mappingDefinitionRecord.findUnique({ + where: { id: definition.versionId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(definition)) + throw new Error('DSM_IMMUTABLE_MAPPING'); + return; + } + await this.client.mappingDefinitionRecord.create({ data: domainToRow(definition) }); + } + + public async find( + context: IamTenantContextV1, + versionId: MappingDefinitionV1['versionId'], + ): Promise { + const row = await this.client.mappingDefinitionRecord.findUnique({ where: { id: versionId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetId: MappingDefinitionV1['datasetId'], + ): Promise { + const rows = await this.client.mappingDefinitionRecord.findMany({ + where: { datasetId, organizationId: context.tenantScope.organizationId }, + orderBy: { createdAt: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaMappingRepositoryAdapter implements MappingRepositoryPortV1 { + public constructor(private readonly client: MappingDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: MappingTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaMappingTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, definition: MappingDefinitionV1): Promise { + return new PrismaMappingTransactionAdapter(this.client).save(context, definition); + } + public find( + context: IamTenantContextV1, + versionId: MappingDefinitionV1['versionId'], + ): Promise { + return new PrismaMappingTransactionAdapter(this.client).find(context, versionId); + } + public list( + context: IamTenantContextV1, + datasetId: MappingDefinitionV1['datasetId'], + ): Promise { + return new PrismaMappingTransactionAdapter(this.client).list(context, datasetId); + } +} diff --git a/services/api/test/features/dsm/prisma-mapping-repository.test.ts b/services/api/test/features/dsm/prisma-mapping-repository.test.ts new file mode 100644 index 00000000..5002b8f1 --- /dev/null +++ b/services/api/test/features/dsm/prisma-mapping-repository.test.ts @@ -0,0 +1,94 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createMappingDefinitionV1 } from '@databreeze/domain/mapping/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaMappingRepositoryAdapter, + type MappingDatabaseClientV1, + type MappingDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-mapping-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture id rejected'); + return result.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000201'); +const workspaceId = id('00000000-0000-4000-8000-000000000202'); +const versionId = id('00000000-0000-4000-8000-000000000203'); +const datasetId = id('00000000-0000-4000-8000-000000000204'); +const sourceSchemaVersionId = id('00000000-0000-4000-8000-000000000205'); +const targetSchemaVersionId = id('00000000-0000-4000-8000-000000000206'); +const sourceFieldId = id('00000000-0000-4000-8000-000000000207'); +const targetFieldId = id('00000000-0000-4000-8000-000000000208'); + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000209', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000210', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: MappingDatabaseRowV1[]): MappingDatabaseClientV1 { + return { + mappingDefinitionRecord: { + create(input) { + const persisted = { ...input.data } as MappingDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique(input) { + return Promise.resolve(rows.find((candidate) => candidate.id === input.where.id) ?? null); + }, + findMany(input) { + return Promise.resolve( + rows + .filter((candidate) => candidate.datasetId === input.where['datasetId']) + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-007, IAM-009] Prisma mapping adapter persists and lists typed mappings', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const created = createMappingDefinitionV1({ + datasetId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceSchemaVersionId, + targetSchemaVersionId, + steps: [{ sourceFieldId, targetFieldId, transform: 'TRIM' }], + createdAt: createdAt.value, + canonicalHash: 'b'.repeat(64), + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('fixture mapping rejected'); + const rows: MappingDatabaseRowV1[] = []; + const repository = new PrismaMappingRepositoryAdapter(client(rows)); + await repository.save(context('save'), created.value); + assert.deepEqual( + (await repository.list(context('list'), datasetId)).map((item) => item.versionId), + [versionId], + ); +}); From 662ba3877a2c9738aecf0de5d4915858793ba090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:43:28 +0700 Subject: [PATCH 32/44] feat(api): wire Prisma mapping composition --- services/api/src/features/dsm/dsm.module.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index e852d155..523f9b88 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -10,6 +10,10 @@ import { type GovernedDatasetDatabaseClientV1, } from './adapter/prisma-governed-dataset-repository.adapter.js'; import { InMemoryMappingRepositoryAdapter } from './adapter/in-memory-mapping-repository.adapter.js'; +import { + PrismaMappingRepositoryAdapter, + type MappingDatabaseClientV1, +} from './adapter/prisma-mapping-repository.adapter.js'; import { InMemoryReferenceEntityRepositoryAdapter } from './adapter/in-memory-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; import { @@ -39,6 +43,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly governedDatasetDatabase?: GovernedDatasetDatabaseClientV1; readonly mappingRepository?: MappingRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly mappingDatabase?: MappingDatabaseClientV1; readonly ruleSetRepository?: RuleSetRepositoryPortV1; readonly referenceEntityRepository?: ReferenceEntityRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; @@ -66,7 +72,11 @@ export class DsmModule { }, { provide: MAPPING_REPOSITORY_PORT, - useValue: options.mappingRepository ?? new InMemoryMappingRepositoryAdapter(), + useValue: + options.mappingRepository ?? + (options.mappingDatabase === undefined + ? new InMemoryMappingRepositoryAdapter() + : new PrismaMappingRepositoryAdapter(options.mappingDatabase)), }, { provide: RULE_SET_REPOSITORY_PORT, From 836305289ddc86ecbdd4907831141d6ddd8742b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:45:07 +0700 Subject: [PATCH 33/44] feat(api): add durable rule-set repository --- .../prisma-rule-set-repository.adapter.ts | 178 ++++++++++++++++++ .../dsm/prisma-rule-set-repository.test.ts | 89 +++++++++ 2 files changed, 267 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/prisma-rule-set-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-rule-set-repository.test.ts diff --git a/services/api/src/features/dsm/adapter/prisma-rule-set-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-rule-set-repository.adapter.ts new file mode 100644 index 00000000..689c5196 --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-rule-set-repository.adapter.ts @@ -0,0 +1,178 @@ +import { + createRuleSetDefinitionV1, + type RuleSetDefinitionV1, +} from '@databreeze/domain/rule-set/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + RuleSetRepositoryPortV1, + RuleSetTransactionPortV1, +} from '../application/rule-set-repository.port.js'; + +export interface RuleSetDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly schemaVersionId: string; + readonly rules: unknown; + readonly status: string; + readonly createdAt: Date; + readonly publishedAt: Date | null; + readonly canonicalHash: string; +} + +export interface RuleSetDatabaseCreateDataV1 + extends Omit { + readonly rules: unknown; + readonly createdAt: Date; + readonly publishedAt: Date | null; + readonly revision: number; +} + +export interface RuleSetDatabaseClientV1 { + readonly ruleSetDefinitionRecord: { + create(input: { readonly data: RuleSetDatabaseCreateDataV1 }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly createdAt: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: RuleSetDatabaseClientV1) => Promise, + ): Promise; +} + +function rowScope(row: RuleSetDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowToDomain(row: RuleSetDatabaseRowV1): RuleSetDefinitionV1 { + const parsed = createRuleSetDefinitionV1({ + datasetId: row.datasetId, + versionId: row.id, + tenantScope: rowScope(row), + schemaVersionId: row.schemaVersionId, + rules: row.rules, + status: row.status, + createdAt: row.createdAt.toISOString(), + ...(row.publishedAt === null ? {} : { publishedAt: row.publishedAt.toISOString() }), + canonicalHash: row.canonicalHash, + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_RULE_SET_INVALID'); + return parsed.value; +} + +function domainToRow(definition: RuleSetDefinitionV1): RuleSetDatabaseCreateDataV1 { + return { + ...databaseScope(definition.tenantScope), + id: definition.versionId, + datasetId: definition.datasetId, + schemaVersionId: definition.schemaVersionId, + rules: definition.rules, + status: definition.status, + createdAt: new Date(definition.createdAt), + publishedAt: definition.publishedAt === undefined ? null : new Date(definition.publishedAt), + revision: 1, + canonicalHash: definition.canonicalHash, + }; +} + +function visible(context: TenantScopeV1, row: RuleSetDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaRuleSetTransactionAdapter implements RuleSetTransactionPortV1 { + public constructor(private readonly client: RuleSetDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, definition: RuleSetDefinitionV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.ruleSetDefinitionRecord.findUnique({ + where: { id: definition.versionId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(definition)) + throw new Error('DSM_IMMUTABLE_RULE_SET'); + return; + } + await this.client.ruleSetDefinitionRecord.create({ data: domainToRow(definition) }); + } + + public async find( + context: IamTenantContextV1, + versionId: RuleSetDefinitionV1['versionId'], + ): Promise { + const row = await this.client.ruleSetDefinitionRecord.findUnique({ where: { id: versionId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetId: RuleSetDefinitionV1['datasetId'], + ): Promise { + const rows = await this.client.ruleSetDefinitionRecord.findMany({ + where: { datasetId, organizationId: context.tenantScope.organizationId }, + orderBy: { createdAt: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaRuleSetRepositoryAdapter implements RuleSetRepositoryPortV1 { + public constructor(private readonly client: RuleSetDatabaseClientV1) {} + public withTransaction( + context: IamTenantContextV1, + work: (transaction: RuleSetTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaRuleSetTransactionAdapter(transaction)), + ); + } + public save(context: IamTenantContextV1, definition: RuleSetDefinitionV1): Promise { + return new PrismaRuleSetTransactionAdapter(this.client).save(context, definition); + } + public find( + context: IamTenantContextV1, + versionId: RuleSetDefinitionV1['versionId'], + ): Promise { + return new PrismaRuleSetTransactionAdapter(this.client).find(context, versionId); + } + public list( + context: IamTenantContextV1, + datasetId: RuleSetDefinitionV1['datasetId'], + ): Promise { + return new PrismaRuleSetTransactionAdapter(this.client).list(context, datasetId); + } +} diff --git a/services/api/test/features/dsm/prisma-rule-set-repository.test.ts b/services/api/test/features/dsm/prisma-rule-set-repository.test.ts new file mode 100644 index 00000000..863c5975 --- /dev/null +++ b/services/api/test/features/dsm/prisma-rule-set-repository.test.ts @@ -0,0 +1,89 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createRuleSetDefinitionV1 } from '@databreeze/domain/rule-set/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaRuleSetRepositoryAdapter, + type RuleSetDatabaseClientV1, + type RuleSetDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-rule-set-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture id rejected'); + return result.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000301'); +const workspaceId = id('00000000-0000-4000-8000-000000000302'); +const datasetId = id('00000000-0000-4000-8000-000000000303'); +const versionId = id('00000000-0000-4000-8000-000000000304'); +const schemaVersionId = id('00000000-0000-4000-8000-000000000305'); +const ruleId = id('00000000-0000-4000-8000-000000000306'); +const fieldId = id('00000000-0000-4000-8000-000000000307'); + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000308', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000309', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: RuleSetDatabaseRowV1[]): RuleSetDatabaseClientV1 { + return { + ruleSetDefinitionRecord: { + create(input) { + const persisted = { ...input.data } as RuleSetDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique(input) { + return Promise.resolve(rows.find((candidate) => candidate.id === input.where.id) ?? null); + }, + findMany(input) { + return Promise.resolve( + rows + .filter((candidate) => candidate.datasetId === input.where['datasetId']) + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-009, DSM-010, IAM-009] Prisma rule-set adapter persists and lists declarative rules', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const created = createRuleSetDefinitionV1({ + datasetId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + schemaVersionId, + rules: [{ ruleId, fieldId, kind: 'REQUIRED', severity: 'ERROR', parameters: {} }], + createdAt: createdAt.value, + canonicalHash: 'c'.repeat(64), + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('fixture rule set rejected'); + const rows: RuleSetDatabaseRowV1[] = []; + const repository = new PrismaRuleSetRepositoryAdapter(client(rows)); + await repository.save(context('save'), created.value); + assert.equal((await repository.list(context('list'), datasetId)).length, 1); +}); From 0514ee966064c2dceb9313e1a3a5ad8df1b9d1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:45:37 +0700 Subject: [PATCH 34/44] feat(api): wire Prisma rule-set composition --- services/api/src/features/dsm/dsm.module.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 523f9b88..c06f3f79 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -16,6 +16,10 @@ import { } from './adapter/prisma-mapping-repository.adapter.js'; import { InMemoryReferenceEntityRepositoryAdapter } from './adapter/in-memory-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; +import { + PrismaRuleSetRepositoryAdapter, + type RuleSetDatabaseClientV1, +} from './adapter/prisma-rule-set-repository.adapter.js'; import { GOVERNED_DATASET_REPOSITORY_PORT, type GovernedDatasetRepositoryPortV1, @@ -46,6 +50,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly mappingDatabase?: MappingDatabaseClientV1; readonly ruleSetRepository?: RuleSetRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly ruleSetDatabase?: RuleSetDatabaseClientV1; readonly referenceEntityRepository?: ReferenceEntityRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -80,7 +86,11 @@ export class DsmModule { }, { provide: RULE_SET_REPOSITORY_PORT, - useValue: options.ruleSetRepository ?? new InMemoryRuleSetRepositoryAdapter(), + useValue: + options.ruleSetRepository ?? + (options.ruleSetDatabase === undefined + ? new InMemoryRuleSetRepositoryAdapter() + : new PrismaRuleSetRepositoryAdapter(options.ruleSetDatabase)), }, { provide: REFERENCE_ENTITY_REPOSITORY_PORT, From 5f93e2c911bccb07a8dd48262d01cb8fa0bb6ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:48:29 +0700 Subject: [PATCH 35/44] feat(api): add durable reference entity repository --- ...sma-reference-entity-repository.adapter.ts | 330 ++++++++++++++++++ ...prisma-reference-entity-repository.test.ts | 103 ++++++ 2 files changed, 433 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/prisma-reference-entity-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-reference-entity-repository.test.ts diff --git a/services/api/src/features/dsm/adapter/prisma-reference-entity-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-reference-entity-repository.adapter.ts new file mode 100644 index 00000000..193887e0 --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-reference-entity-repository.adapter.ts @@ -0,0 +1,330 @@ +import { + createBusinessPartyVersionV1, + type BusinessPartyResolutionV1, + type BusinessPartyVersionV1, +} from '@databreeze/domain/reference-entity/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ReferenceEntityRepositoryPortV1, + ReferenceEntityTransactionPortV1, +} from '../application/reference-entity-repository.port.js'; + +export interface ReferenceEntityDatabaseRowV1 { + readonly id: string; + readonly entityId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly entityType: string; + readonly displayName: string; + readonly roles: unknown; + readonly aliases: unknown; + readonly externalIdentifiers: unknown; + readonly status: string; + readonly visibility: string; + readonly canonicalHash: string; + readonly createdAt: Date; +} + +export interface ReferenceResolutionDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly sourceEntityId: string; + readonly targetEntityId: string; + readonly actorId: string; + readonly reason: string; + readonly evidenceId: string; + readonly resolvedAt: Date; +} + +interface ReferenceEntityCreateDataV1 extends Omit { + readonly createdAt: Date; +} +interface ReferenceResolutionCreateDataV1 + extends Omit { + readonly resolvedAt: Date; +} + +export interface ReferenceEntityDatabaseClientV1 { + readonly referenceEntityVersionRecord: { + create(input: { + readonly data: ReferenceEntityCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly createdAt: 'desc' }; + }): Promise; + }; + readonly referenceEntityResolutionRecord: { + create(input: { + readonly data: ReferenceResolutionCreateDataV1; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly resolvedAt: 'desc' }; + }): Promise; + }; + $transaction( + work: (transaction: ReferenceEntityDatabaseClientV1) => Promise, + ): Promise; +} + +function scopeForRow( + row: ReferenceEntityDatabaseRowV1 | ReferenceResolutionDatabaseRowV1, +): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowToVersion(row: ReferenceEntityDatabaseRowV1): BusinessPartyVersionV1 { + const parsed = createBusinessPartyVersionV1({ + entityId: row.entityId, + versionId: row.id, + tenantScope: scopeForRow(row), + displayName: row.displayName, + roles: row.roles, + aliases: row.aliases, + externalIdentifiers: row.externalIdentifiers, + status: row.status, + visibility: row.visibility, + canonicalHash: row.canonicalHash, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_REFERENCE_ENTITY_INVALID'); + return parsed.value; +} + +function rowToResolution(row: ReferenceResolutionDatabaseRowV1): BusinessPartyResolutionV1 { + const resolutionId = parseStableIdentifierV1(row.id); + const sourceEntityId = parseStableIdentifierV1(row.sourceEntityId); + const targetEntityId = parseStableIdentifierV1(row.targetEntityId); + const actorId = parseStableIdentifierV1(row.actorId); + const evidenceId = parseStableIdentifierV1(row.evidenceId); + const resolvedAt = parseStrictUtcTimestampV1(row.resolvedAt.toISOString()); + if ( + !resolutionId.accepted || + !sourceEntityId.accepted || + !targetEntityId.accepted || + !actorId.accepted || + !evidenceId.accepted || + !resolvedAt.accepted || + row.reason.length === 0 || + row.reason.length > 512 + ) { + throw new Error('DSM_PERSISTED_RESOLUTION_INVALID'); + } + return Object.freeze({ + schemaVersion: 1, + resolutionId: resolutionId.value, + resolutionType: 'MERGE' as const, + sourceEntityId: sourceEntityId.value, + targetEntityId: targetEntityId.value, + actorId: actorId.value, + reason: row.reason, + evidenceId: evidenceId.value, + resolvedAt: resolvedAt.value, + }); +} + +function visible( + context: TenantScopeV1, + row: ReferenceEntityDatabaseRowV1 | ReferenceResolutionDatabaseRowV1, +): boolean { + const candidate = scopeForRow(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaReferenceEntityTransactionAdapter implements ReferenceEntityTransactionPortV1 { + public constructor(private readonly client: ReferenceEntityDatabaseClientV1) {} + + public async saveVersion( + context: IamTenantContextV1, + version: BusinessPartyVersionV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.referenceEntityVersionRecord.findUnique({ + where: { id: version.versionId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToVersion(existing)) !== JSON.stringify(version)) + throw new Error('DSM_IMMUTABLE_REFERENCE_VERSION'); + return; + } + await this.client.referenceEntityVersionRecord.create({ + data: { + ...databaseScope(version.tenantScope), + id: version.versionId, + entityId: version.entityId, + entityType: version.entityType, + displayName: version.displayName, + roles: version.roles, + aliases: version.aliases, + externalIdentifiers: version.externalIdentifiers, + status: version.status, + visibility: version.visibility, + canonicalHash: version.canonicalHash, + createdAt: new Date(version.createdAt), + }, + }); + } + + public async findVersion( + context: IamTenantContextV1, + versionId: BusinessPartyVersionV1['versionId'], + ): Promise { + const row = await this.client.referenceEntityVersionRecord.findUnique({ + where: { id: versionId }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToVersion(row) + : undefined; + } + + public async findLatest( + context: IamTenantContextV1, + entityId: BusinessPartyVersionV1['entityId'], + ): Promise { + const versions = await this.listVersions(context, entityId); + return versions[0]; + } + + public async listVersions( + context: IamTenantContextV1, + entityId: BusinessPartyVersionV1['entityId'], + ): Promise { + const rows = await this.client.referenceEntityVersionRecord.findMany({ + where: { entityId, organizationId: context.tenantScope.organizationId }, + orderBy: { createdAt: 'desc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToVersion); + } + + public async saveResolution( + context: IamTenantContextV1, + resolution: BusinessPartyResolutionV1, + ): Promise { + const existing = await this.client.referenceEntityResolutionRecord.findMany({ + where: { + organizationId: context.tenantScope.organizationId, + sourceEntityId: resolution.sourceEntityId, + }, + orderBy: { resolvedAt: 'desc' }, + }); + if (existing.some((row) => row.id === resolution.resolutionId)) { + if ( + JSON.stringify( + rowToResolution(existing.find((row) => row.id === resolution.resolutionId)!), + ) !== JSON.stringify(resolution) + ) + throw new Error('DSM_IMMUTABLE_RESOLUTION'); + return; + } + await this.client.referenceEntityResolutionRecord.create({ + data: { + ...databaseScope(context.tenantScope), + id: resolution.resolutionId, + sourceEntityId: resolution.sourceEntityId, + targetEntityId: resolution.targetEntityId, + actorId: resolution.actorId, + reason: resolution.reason, + evidenceId: resolution.evidenceId, + resolvedAt: new Date(resolution.resolvedAt), + }, + }); + } + + public async listResolutions( + context: IamTenantContextV1, + entityId: BusinessPartyVersionV1['entityId'], + ): Promise { + const rows = await this.client.referenceEntityResolutionRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId, sourceEntityId: entityId }, + orderBy: { resolvedAt: 'desc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToResolution); + } +} + +export class PrismaReferenceEntityRepositoryAdapter implements ReferenceEntityRepositoryPortV1 { + public constructor(private readonly client: ReferenceEntityDatabaseClientV1) {} + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ReferenceEntityTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaReferenceEntityTransactionAdapter(transaction)), + ); + } + public saveVersion(context: IamTenantContextV1, version: BusinessPartyVersionV1): Promise { + return new PrismaReferenceEntityTransactionAdapter(this.client).saveVersion(context, version); + } + public findVersion( + context: IamTenantContextV1, + versionId: BusinessPartyVersionV1['versionId'], + ): Promise { + return new PrismaReferenceEntityTransactionAdapter(this.client).findVersion(context, versionId); + } + public findLatest( + context: IamTenantContextV1, + entityId: BusinessPartyVersionV1['entityId'], + ): Promise { + return new PrismaReferenceEntityTransactionAdapter(this.client).findLatest(context, entityId); + } + public listVersions( + context: IamTenantContextV1, + entityId: BusinessPartyVersionV1['entityId'], + ): Promise { + return new PrismaReferenceEntityTransactionAdapter(this.client).listVersions(context, entityId); + } + public saveResolution( + context: IamTenantContextV1, + resolution: BusinessPartyResolutionV1, + ): Promise { + return new PrismaReferenceEntityTransactionAdapter(this.client).saveResolution( + context, + resolution, + ); + } + public listResolutions( + context: IamTenantContextV1, + entityId: BusinessPartyVersionV1['entityId'], + ): Promise { + return new PrismaReferenceEntityTransactionAdapter(this.client).listResolutions( + context, + entityId, + ); + } +} diff --git a/services/api/test/features/dsm/prisma-reference-entity-repository.test.ts b/services/api/test/features/dsm/prisma-reference-entity-repository.test.ts new file mode 100644 index 00000000..87732a63 --- /dev/null +++ b/services/api/test/features/dsm/prisma-reference-entity-repository.test.ts @@ -0,0 +1,103 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createBusinessPartyVersionV1 } from '@databreeze/domain/reference-entity/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaReferenceEntityRepositoryAdapter, + type ReferenceEntityDatabaseClientV1, + type ReferenceEntityDatabaseRowV1, + type ReferenceResolutionDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-reference-entity-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture id rejected'); + return result.value; +} +const organizationId = id('00000000-0000-4000-8000-000000000401'); +const workspaceId = id('00000000-0000-4000-8000-000000000402'); +const entityId = id('00000000-0000-4000-8000-000000000403'); +const versionId = id('00000000-0000-4000-8000-000000000404'); + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000405', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000406', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client( + rows: ReferenceEntityDatabaseRowV1[], + resolutions: ReferenceResolutionDatabaseRowV1[], +): ReferenceEntityDatabaseClientV1 { + return { + referenceEntityVersionRecord: { + create(input) { + const persisted = { ...input.data } as ReferenceEntityDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique(input) { + return Promise.resolve(rows.find((candidate) => candidate.id === input.where.id) ?? null); + }, + findMany(input) { + return Promise.resolve( + rows + .filter((candidate) => candidate.entityId === input.where['entityId']) + .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()), + ); + }, + }, + referenceEntityResolutionRecord: { + create(input) { + const persisted = { ...input.data } as ReferenceResolutionDatabaseRowV1; + resolutions.push(persisted); + return Promise.resolve(persisted); + }, + findMany(input) { + return Promise.resolve( + resolutions.filter( + (candidate) => candidate.sourceEntityId === input.where['sourceEntityId'], + ), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-025, IAM-009] Prisma reference entity adapter preserves immutable versions and scope', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const version = createBusinessPartyVersionV1({ + entityId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + displayName: 'Supplier', + roles: ['SUPPLIER'], + canonicalHash: 'd'.repeat(64), + createdAt: createdAt.value, + }); + assert.equal(version.accepted, true); + if (!version.accepted) throw new Error('fixture entity rejected'); + const rows: ReferenceEntityDatabaseRowV1[] = []; + const repository = new PrismaReferenceEntityRepositoryAdapter(client(rows, [])); + await repository.saveVersion(context('save'), version.value); + assert.equal((await repository.findLatest(context('latest'), entityId))?.displayName, 'Supplier'); +}); From 02ef82c6215d7b7e92e5e5ec37b8290034622d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:48:59 +0700 Subject: [PATCH 36/44] feat(api): wire Prisma reference entity composition --- services/api/src/features/dsm/dsm.module.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index c06f3f79..3bdb53f6 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -15,6 +15,10 @@ import { type MappingDatabaseClientV1, } from './adapter/prisma-mapping-repository.adapter.js'; import { InMemoryReferenceEntityRepositoryAdapter } from './adapter/in-memory-reference-entity-repository.adapter.js'; +import { + PrismaReferenceEntityRepositoryAdapter, + type ReferenceEntityDatabaseClientV1, +} from './adapter/prisma-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; import { PrismaRuleSetRepositoryAdapter, @@ -53,6 +57,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly ruleSetDatabase?: RuleSetDatabaseClientV1; readonly referenceEntityRepository?: ReferenceEntityRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly referenceEntityDatabase?: ReferenceEntityDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -95,7 +101,10 @@ export class DsmModule { { provide: REFERENCE_ENTITY_REPOSITORY_PORT, useValue: - options.referenceEntityRepository ?? new InMemoryReferenceEntityRepositoryAdapter(), + options.referenceEntityRepository ?? + (options.referenceEntityDatabase === undefined + ? new InMemoryReferenceEntityRepositoryAdapter() + : new PrismaReferenceEntityRepositoryAdapter(options.referenceEntityDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, From 1233bb6632866ee3c165606ab66ff144b00f2bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:51:59 +0700 Subject: [PATCH 37/44] feat(api): wire Prisma artifact composition --- services/api/src/features/iae/iae.module.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index bfc87f4a..bff8b5a2 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -8,6 +8,10 @@ import { type ArtifactIntakeDatabaseClientV1, } from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; +import { + PrismaArtifactRepositoryAdapter, + type ArtifactDatabaseClientV1, +} from './adapter/prisma-artifact-repository.adapter.js'; import { InMemoryEvidenceGrantRepositoryAdapter } from './adapter/in-memory-evidence-grant-repository.adapter.js'; import { ARTIFACT_INTAKE_REPOSITORY_PORT, @@ -32,6 +36,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactIntakeDatabase?: ArtifactIntakeDatabaseClientV1; readonly artifactRepository?: ArtifactRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -53,7 +59,11 @@ export class IaeModule { }, { provide: ARTIFACT_REPOSITORY_PORT, - useValue: options.artifactRepository ?? new InMemoryArtifactRepositoryAdapter(), + useValue: + options.artifactRepository ?? + (options.artifactDatabase === undefined + ? new InMemoryArtifactRepositoryAdapter() + : new PrismaArtifactRepositoryAdapter(options.artifactDatabase)), }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, From 0c7f32e76ece96d420b0fe6d07204392d82eeb92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:52:32 +0700 Subject: [PATCH 38/44] feat(api): add durable artifact repository --- .../prisma-artifact-repository.adapter.ts | 348 ++++++++++++++++++ .../iae/prisma-artifact-repository.test.ts | 144 ++++++++ 2 files changed, 492 insertions(+) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts new file mode 100644 index 00000000..da6d83cb --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -0,0 +1,348 @@ +import { + createArtifactVersionV1, + createContentPlacementV1, + createEvidenceReferenceV1, + type ArtifactVersionV1, + type ContentPlacementV1, + type EvidenceReferenceV1, +} from '@databreeze/domain/artifact/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactRepositoryPortV1, + ArtifactTransactionPortV1, +} from '../application/artifact-repository.port.js'; + +export interface ArtifactVersionDatabaseRowV1 { + readonly id: string; + readonly artifactId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly sourceKind: string; + readonly dataMode: string; + readonly contentSha256: string; + readonly byteSize: bigint | number; + readonly mediaType: string; + readonly displayName: string; + readonly createdAt: Date; + readonly status: string; +} + +export interface ContentPlacementDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly kind: string; + readonly opaqueReference: string; + readonly contentSha256: string; + readonly available: boolean; + readonly revision: number; +} + +export interface EvidenceDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly coordinate: unknown; + readonly sourceState: string; + readonly excerpt: string | null; +} + +interface ArtifactVersionCreateDataV1 + extends Omit { + readonly byteSize: bigint; + readonly createdAt: Date; +} +interface ContentPlacementCreateDataV1 extends Omit { + readonly createdAt: Date; + readonly updatedAt: Date; +} +interface EvidenceCreateDataV1 extends Omit { + readonly createdAt: Date; +} + +export interface ArtifactDatabaseClientV1 { + readonly artifactVersion: { + create(input: { + readonly data: ArtifactVersionCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + }; + readonly contentPlacement: { + create(input: { + readonly data: ContentPlacementCreateDataV1; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + }; + readonly evidenceReference: { + create(input: { readonly data: EvidenceCreateDataV1 }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactDatabaseClientV1) => Promise, + ): Promise; +} + +function rowScope( + row: ArtifactVersionDatabaseRowV1 | ContentPlacementDatabaseRowV1 | EvidenceDatabaseRowV1, +): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowToVersion(row: ArtifactVersionDatabaseRowV1): ArtifactVersionV1 { + const byteSize = typeof row.byteSize === 'bigint' ? Number(row.byteSize) : row.byteSize; + const parsed = createArtifactVersionV1({ + artifactId: row.artifactId, + versionId: row.id, + tenantScope: rowScope(row), + sourceKind: row.sourceKind, + dataMode: row.dataMode, + contentSha256: row.contentSha256, + byteSize, + mediaType: row.mediaType, + displayName: row.displayName, + createdAt: row.createdAt.toISOString(), + status: row.status, + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_ARTIFACT_INVALID'); + return parsed.value; +} + +function rowToPlacement( + row: ContentPlacementDatabaseRowV1, + version: ArtifactVersionV1, +): ContentPlacementV1 { + const parsed = createContentPlacementV1({ + placementId: row.id, + artifactVersion: version, + tenantScope: rowScope(row), + kind: row.kind, + opaqueReference: row.opaqueReference, + contentSha256: row.contentSha256, + available: row.available, + revision: row.revision, + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_PLACEMENT_INVALID'); + return parsed.value; +} + +function rowToEvidence( + row: EvidenceDatabaseRowV1, + version: ArtifactVersionV1, +): EvidenceReferenceV1 { + const parsed = createEvidenceReferenceV1({ + evidenceId: row.id, + artifactVersion: version, + tenantScope: rowScope(row), + coordinate: row.coordinate, + sourceState: row.sourceState, + ...(row.excerpt === null ? {} : { excerpt: row.excerpt }), + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_EVIDENCE_INVALID'); + return parsed.value; +} + +function visible( + context: TenantScopeV1, + row: ArtifactVersionDatabaseRowV1 | ContentPlacementDatabaseRowV1 | EvidenceDatabaseRowV1, +): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { + public constructor(private readonly client: ArtifactDatabaseClientV1) {} + + public async saveVersion(context: IamTenantContextV1, version: ArtifactVersionV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactVersion.findUnique({ + where: { id: version.versionId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToVersion(existing)) !== JSON.stringify(version)) + throw new Error('IAE_IMMUTABLE_VERSION'); + return; + } + await this.client.artifactVersion.create({ + data: { + ...databaseScope(version.tenantScope), + id: version.versionId, + artifactId: version.artifactId, + sourceKind: version.sourceKind, + dataMode: version.dataMode, + contentSha256: version.contentSha256, + byteSize: BigInt(version.byteSize), + mediaType: version.mediaType, + displayName: version.displayName, + createdAt: new Date(version.createdAt), + status: version.status, + }, + }); + } + + public async findVersion( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + ): Promise { + const row = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToVersion(row) + : undefined; + } + + public async savePlacement( + context: IamTenantContextV1, + placement: ContentPlacementV1, + ): Promise { + const version = await this.client.artifactVersion.findUnique({ + where: { id: placement.artifactVersionId }, + }); + if (version === null) throw new Error('IAE_VERSION_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, placement.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + await this.client.contentPlacement.create({ + data: { + ...databaseScope(placement.tenantScope), + id: placement.placementId, + artifactVersionId: placement.artifactVersionId, + kind: placement.kind, + opaqueReference: placement.opaqueReference, + contentSha256: placement.contentSha256, + available: placement.available, + revision: placement.revision, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + public async listPlacements( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + ): Promise { + const versionRow = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); + if (versionRow === null || !visible(context.tenantScope, versionRow)) return []; + const version = rowToVersion(versionRow); + const rows = await this.client.contentPlacement.findMany({ + where: { artifactVersionId: versionId }, + }); + return rows + .filter((row) => visible(context.tenantScope, row)) + .map((row) => rowToPlacement(row, version)); + } + + public async saveEvidence( + context: IamTenantContextV1, + evidence: EvidenceReferenceV1, + ): Promise { + const versionRow = await this.client.artifactVersion.findUnique({ + where: { id: evidence.artifactVersionId }, + }); + if (versionRow === null) throw new Error('IAE_VERSION_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, evidence.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + await this.client.evidenceReference.create({ + data: { + ...databaseScope(evidence.tenantScope), + id: evidence.evidenceId, + artifactVersionId: evidence.artifactVersionId, + coordinate: evidence.coordinate, + sourceState: evidence.sourceState, + excerpt: evidence.excerpt ?? null, + createdAt: new Date(), + }, + }); + } + + public async listEvidence( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + ): Promise { + const versionRow = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); + if (versionRow === null || !visible(context.tenantScope, versionRow)) return []; + const version = rowToVersion(versionRow); + const rows = await this.client.evidenceReference.findMany({ + where: { artifactVersionId: versionId }, + }); + return rows + .filter((row) => visible(context.tenantScope, row)) + .map((row) => rowToEvidence(row, version)); + } +} + +export class PrismaArtifactRepositoryAdapter implements ArtifactRepositoryPortV1 { + public constructor(private readonly client: ArtifactDatabaseClientV1) {} + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactTransactionAdapter(transaction)), + ); + } + public saveVersion(context: IamTenantContextV1, version: ArtifactVersionV1): Promise { + return new PrismaArtifactTransactionAdapter(this.client).saveVersion(context, version); + } + public findVersion( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + ): Promise { + return new PrismaArtifactTransactionAdapter(this.client).findVersion(context, versionId); + } + public savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { + return new PrismaArtifactTransactionAdapter(this.client).savePlacement(context, placement); + } + public listPlacements( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + ): Promise { + return new PrismaArtifactTransactionAdapter(this.client).listPlacements(context, versionId); + } + public saveEvidence(context: IamTenantContextV1, evidence: EvidenceReferenceV1): Promise { + return new PrismaArtifactTransactionAdapter(this.client).saveEvidence(context, evidence); + } + public listEvidence( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + ): Promise { + return new PrismaArtifactTransactionAdapter(this.client).listEvidence(context, versionId); + } +} diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts new file mode 100644 index 00000000..816dd1b7 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -0,0 +1,144 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + createArtifactVersionV1, + createContentPlacementV1, + createEvidenceReferenceV1, +} from '@databreeze/domain/artifact/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaArtifactRepositoryAdapter, + type ArtifactDatabaseClientV1, + type ArtifactVersionDatabaseRowV1, + type ContentPlacementDatabaseRowV1, + type EvidenceDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture id rejected'); + return result.value; +} +const organizationId = id('00000000-0000-4000-8000-000000000501'); +const workspaceId = id('00000000-0000-4000-8000-000000000502'); +const artifactId = id('00000000-0000-4000-8000-000000000503'); +const versionId = id('00000000-0000-4000-8000-000000000504'); +const placementId = id('00000000-0000-4000-8000-000000000505'); +const evidenceId = id('00000000-0000-4000-8000-000000000506'); + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000507', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000508', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client( + versions: ArtifactVersionDatabaseRowV1[], + placements: ContentPlacementDatabaseRowV1[], + evidence: EvidenceDatabaseRowV1[], +): ArtifactDatabaseClientV1 { + return { + artifactVersion: { + create(input) { + const persisted = { ...input.data } as ArtifactVersionDatabaseRowV1; + versions.push(persisted); + return Promise.resolve(persisted); + }, + findUnique(input) { + return Promise.resolve( + versions.find((candidate) => candidate.id === input.where.id) ?? null, + ); + }, + }, + contentPlacement: { + create(input) { + const persisted = { ...input.data } as ContentPlacementDatabaseRowV1; + placements.push(persisted); + return Promise.resolve(persisted); + }, + findMany(input) { + return Promise.resolve( + placements.filter( + (candidate) => candidate.artifactVersionId === input.where['artifactVersionId'], + ), + ); + }, + }, + evidenceReference: { + create(input) { + const persisted = { ...input.data } as EvidenceDatabaseRowV1; + evidence.push(persisted); + return Promise.resolve(persisted); + }, + findMany(input) { + return Promise.resolve( + evidence.filter( + (candidate) => candidate.artifactVersionId === input.where['artifactVersionId'], + ), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps placement and evidence tenant scoped', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const artifact = createArtifactVersionV1({ + artifactId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'e'.repeat(64), + byteSize: 8, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: createdAt.value, + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) throw new Error('fixture artifact rejected'); + const placement = createContentPlacementV1({ + placementId, + artifactVersion: artifact.value, + tenantScope: artifact.value.tenantScope, + kind: 'CLOUD', + opaqueReference: 'opaque-reference-1234', + contentSha256: artifact.value.contentSha256, + }); + const evidenceRef = createEvidenceReferenceV1({ + evidenceId, + artifactVersion: artifact.value, + tenantScope: artifact.value.tenantScope, + coordinate: { kind: 'ROW', row: 1 }, + }); + assert.equal(placement.accepted, true); + assert.equal(evidenceRef.accepted, true); + if (!placement.accepted || !evidenceRef.accepted) throw new Error('fixture child rejected'); + const placements: ContentPlacementDatabaseRowV1[] = []; + const evidence: EvidenceDatabaseRowV1[] = []; + const repository = new PrismaArtifactRepositoryAdapter(client([], placements, evidence)); + await repository.saveVersion(context('version'), artifact.value); + await repository.savePlacement(context('placement'), placement.value); + await repository.saveEvidence(context('evidence'), evidenceRef.value); + assert.equal((await repository.listPlacements(context('list-placement'), versionId)).length, 1); + assert.equal((await repository.listEvidence(context('list-evidence'), versionId)).length, 1); +}); From 50315abac0d45f3d83f8da009bd090bd5ade9c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:53:39 +0700 Subject: [PATCH 39/44] test(api): exercise scoped inbox endpoint --- .../features/iae/inbox.controller.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 services/api/test/features/iae/inbox.controller.test.ts diff --git a/services/api/test/features/iae/inbox.controller.test.ts b/services/api/test/features/iae/inbox.controller.test.ts new file mode 100644 index 00000000..14ead4f9 --- /dev/null +++ b/services/api/test/features/iae/inbox.controller.test.ts @@ -0,0 +1,56 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactIntakeRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.js'; +import { ArtifactIntakeService } from '../../../src/features/iae/application/artifact-intake.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000601'; +const workspaceId = '00000000-0000-4000-8000-000000000602'; +const inboxItemId = '00000000-0000-4000-8000-000000000603'; +const artifactVersionId = '00000000-0000-4000-8000-000000000604'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000605', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000606', + idempotencyKey: 'http-inbox', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-001, IAM-009] HTTP inbox listing uses the configured tenant context and returns no source content', async () => { + const repository = new InMemoryArtifactIntakeRepositoryAdapter(); + const tenantContext = context(); + const intake = new ArtifactIntakeService(repository); + const created = await intake.create(tenantContext, { + inboxItemId, + tenantScope: tenantContext.tenantScope, + idempotencyKey: 'http-inbox-item', + artifactVersionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactIntakeRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ method: '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); + } finally { + await app.close(); + } +}); From bcaaa31007cd8f65418e7d54ec3c504bfc655543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:54:30 +0700 Subject: [PATCH 40/44] fix(style): format foundation implementation --- packages/domain/src/artifact-governance/v1.ts | 21 +++- packages/domain/src/dataset-governance/v1.ts | 18 ++- packages/domain/src/evidence-grant/v1.ts | 72 ++++++++---- packages/domain/src/mapping/v1.ts | 75 ++++++++---- packages/domain/src/reference-entity/v1.ts | 9 +- packages/domain/src/rule-set/v1.ts | 97 +++++++++++++--- .../test/artifact-governance-v1.test.mjs | 22 ++-- packages/domain/test/artifact-v1.test.mjs | 5 +- .../domain/test/built-public-api-smoke.mjs | 20 ++-- .../test/dataset-governance-v1.test.mjs | 4 +- .../domain/test/evidence-grant-v1.test.mjs | 42 ++++++- .../domain/test/mapping-rule-set-v1.test.mjs | 105 ++++++++++++++--- packages/domain/test/public-api-v1.test.mjs | 14 +-- .../domain/test/reference-entity-v1.test.mjs | 13 ++- services/api/openapi/v1.json | 26 +---- services/api/src/app.module.ts | 5 +- services/api/src/bootstrap.ts | 5 +- ...ory-governed-dataset-repository.adapter.ts | 3 +- .../in-memory-mapping-repository.adapter.ts | 35 ++++-- ...ory-reference-entity-repository.adapter.ts | 96 +++++++++++---- .../in-memory-rule-set-repository.adapter.ts | 47 ++++++-- .../dsm/api/governed-dataset.controller.ts | 5 +- .../features/dsm/api/governed-dataset.dto.ts | 13 ++- .../features/dsm/api/mapping.controller.ts | 27 ++++- .../api/src/features/dsm/api/mapping.dto.ts | 26 ++++- .../dsm/api/reference-entity.controller.ts | 10 +- .../features/dsm/api/reference-entity.dto.ts | 11 +- .../features/dsm/api/rule-set.controller.ts | 27 ++++- .../application/governed-dataset.service.ts | 13 ++- .../application/mapping-repository.port.ts | 15 ++- .../dsm/application/mapping.service.ts | 28 ++++- .../reference-entity-repository.port.ts | 30 ++++- .../application/reference-entity.service.ts | 69 +++++++---- .../application/rule-set-repository.port.ts | 15 ++- .../dsm/application/rule-set.service.ts | 28 ++++- ...mory-artifact-intake-repository.adapter.ts | 12 +- ...ory-artifact-lineage-repository.adapter.ts | 4 +- ...emory-evidence-grant-repository.adapter.ts | 43 +++++-- .../iae/api/evidence-grant.controller.ts | 22 +++- .../artifact-governance.service.ts | 5 +- .../artifact-intake-repository.port.ts | 5 +- .../application/artifact-intake.service.ts | 16 ++- .../artifact-lineage-repository.port.ts | 5 +- .../application/derived-artifact.service.ts | 37 +++--- .../evidence-grant-repository.port.ts | 10 +- .../iae/application/evidence-grant.service.ts | 109 +++++++++++++----- .../dsm/governed-dataset.service.test.ts | 20 +++- .../test/features/dsm/mapping.service.test.ts | 43 +++++-- .../dsm/reference-entity.service.test.ts | 84 ++++++++++++-- .../features/dsm/rule-set.service.test.ts | 43 ++++++- .../iae/artifact-governance.service.test.ts | 16 ++- .../iae/artifact-intake.service.test.ts | 70 ++++++++--- .../iae/derived-artifact.service.test.ts | 64 ++++++++-- .../iae/evidence-grant.service.test.ts | 71 ++++++++++-- 54 files changed, 1326 insertions(+), 404 deletions(-) diff --git a/packages/domain/src/artifact-governance/v1.ts b/packages/domain/src/artifact-governance/v1.ts index 7f57c35f..a23d8957 100644 --- a/packages/domain/src/artifact-governance/v1.ts +++ b/packages/domain/src/artifact-governance/v1.ts @@ -178,11 +178,10 @@ export function validateDerivedArtifactVersionV1(input: { if (source.status !== 'ACTIVE') return rejected('SOURCE_NOT_ACTIVE'); sourceModeRanks.push(source.dataMode === 'Local' ? 0 : source.dataMode === 'Hybrid' ? 1 : 2); } - const derivedModeRank = input.derived.dataMode === 'Local' ? 0 : input.derived.dataMode === 'Hybrid' ? 1 : 2; + const derivedModeRank = + input.derived.dataMode === 'Local' ? 0 : input.derived.dataMode === 'Hybrid' ? 1 : 2; const leastPermissiveSource = Math.min(...sourceModeRanks); - return derivedModeRank <= leastPermissiveSource - ? accepted(true) - : rejected('DATA_MODE_WIDENING'); + return derivedModeRank <= leastPermissiveSource ? accepted(true) : rejected('DATA_MODE_WIDENING'); } export function evaluateArtifactRetentionV1(input: { @@ -199,7 +198,13 @@ export function evaluateArtifactRetentionV1(input: { const resourceRetentionUntil = timestamp(input.resourceRetentionUntil); const auditRetentionUntil = timestamp(input.auditRetentionUntil); const recoveryWindowUntil = timestamp(input.recoveryWindowUntil); - if (!evaluatedAt || !workspaceRetentionUntil || !resourceRetentionUntil || !auditRetentionUntil || !recoveryWindowUntil) + if ( + !evaluatedAt || + !workspaceRetentionUntil || + !resourceRetentionUntil || + !auditRetentionUntil || + !recoveryWindowUntil + ) return rejected('INVALID_TIMESTAMP'); if (typeof input.activeApproval !== 'boolean' || typeof input.legalHold !== 'boolean') return rejected('INVALID_LINEAGE'); @@ -212,6 +217,10 @@ export function evaluateArtifactRetentionV1(input: { if (input.activeApproval) blockers.push('ACTIVE_APPROVAL'); if (input.legalHold) blockers.push('LEGAL_HOLD'); return accepted( - Object.freeze({ eligible: blockers.length === 0, blockers: Object.freeze(blockers), evaluatedAt }), + Object.freeze({ + eligible: blockers.length === 0, + blockers: Object.freeze(blockers), + evaluatedAt, + }), ); } diff --git a/packages/domain/src/dataset-governance/v1.ts b/packages/domain/src/dataset-governance/v1.ts index 75917ad2..89901f60 100644 --- a/packages/domain/src/dataset-governance/v1.ts +++ b/packages/domain/src/dataset-governance/v1.ts @@ -131,7 +131,11 @@ function field(input: unknown): GovernedDatasetFieldV1 | undefined { const localizedInput = record['localizedLabels'] === undefined ? {} : record['localizedLabels']; const sensitivity = record['sensitivity'] ?? 'PUBLIC'; const defaultBehavior = record['defaultBehavior'] ?? 'NONE'; - if (!fieldId || !name || !['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes(type as string)) + if ( + !fieldId || + !name || + !['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes(type as string) + ) return undefined; if (typeof nullable !== 'boolean') return undefined; if (record['unit'] !== undefined && !unit) return undefined; @@ -203,7 +207,8 @@ export function createGovernedDatasetDefinitionV1(input: { if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) return rejected('INVALID_TIMESTAMP'); const status = input.status ?? 'DRAFT'; - if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) return rejected('INVALID_STATE'); + if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) + return rejected('INVALID_STATE'); if (!canonicalHash) return rejected('INVALID_HASH'); if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) return rejected('INVALID_TIMESTAMP'); @@ -227,7 +232,10 @@ export function compareGovernedSchemaCompatibilityV1( previous: GovernedDatasetDefinitionV1, next: GovernedDatasetDefinitionV1, ): DatasetGovernanceResultV1 { - if (previous.datasetId !== next.datasetId || !tenantScopesEqualV1(previous.tenantScope, next.tenantScope)) + if ( + previous.datasetId !== next.datasetId || + !tenantScopesEqualV1(previous.tenantScope, next.tenantScope) + ) return rejected('INCOMPATIBLE_SCHEMA'); const nextById = new Map(next.fields.map((candidate) => [candidate.fieldId, candidate])); let classification: SchemaCompatibilityV1 = 'ADDITIVE_COMPATIBLE'; @@ -305,7 +313,9 @@ export function createDatasetVersionManifestV1(input: { input.rowCount < 0 ) return rejected('INVALID_COUNT'); - if (!['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'].includes(input.qualityState as string)) + if ( + !['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'].includes(input.qualityState as string) + ) return rejected('INVALID_QUALITY_STATE'); return accepted( Object.freeze({ diff --git a/packages/domain/src/evidence-grant/v1.ts b/packages/domain/src/evidence-grant/v1.ts index 46880929..0ec86fcd 100644 --- a/packages/domain/src/evidence-grant/v1.ts +++ b/packages/domain/src/evidence-grant/v1.ts @@ -86,35 +86,59 @@ export function createEvidenceAccessGrantV1(input: { const recipientDeviceId = identifier(input.recipientDeviceId); const issuedAt = timestamp(input.issuedAt); const expiresAt = timestamp(input.expiresAt); - if (!grantId || !evidenceId || !artifactVersionId || !recipientDeviceId) return rejected('INVALID_IDENTIFIER'); + if (!grantId || !evidenceId || !artifactVersionId || !recipientDeviceId) + return rejected('INVALID_IDENTIFIER'); if (!tenantScope) return rejected('INVALID_SCOPE'); - if (!issuedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(issuedAt)) return rejected('INVALID_TIMESTAMP'); - if (Date.parse(expiresAt) - Date.parse(issuedAt) > 15 * 60 * 1000) return rejected('EXPIRY_TOO_LONG'); - if (!['COORDINATE', 'EXCERPT', 'OPEN_ON_DEVICE'].includes(input.action as string)) return rejected('INVALID_ACTION'); - if (!['AVAILABLE', 'SOURCE_OFFLINE', 'DELETED'].includes(input.sourceState)) return rejected('SOURCE_UNAVAILABLE'); - if (input.action === 'OPEN_ON_DEVICE' && input.artifactDataMode !== 'Local') return rejected('INVALID_ACTION'); - if (input.action === 'EXCERPT' && input.artifactDataMode === 'Local') return rejected('LOCAL_CONTENT_LEAK'); - if (input.action === 'EXCERPT' && input.sourceState !== 'AVAILABLE') return rejected('SOURCE_UNAVAILABLE'); - if (typeof input.authorizationEpoch !== 'number' || !Number.isSafeInteger(input.authorizationEpoch) || input.authorizationEpoch < 1) return rejected('INVALID_EPOCH'); + if (!issuedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(issuedAt)) + return rejected('INVALID_TIMESTAMP'); + if (Date.parse(expiresAt) - Date.parse(issuedAt) > 15 * 60 * 1000) + return rejected('EXPIRY_TOO_LONG'); + if (!['COORDINATE', 'EXCERPT', 'OPEN_ON_DEVICE'].includes(input.action as string)) + return rejected('INVALID_ACTION'); + if (!['AVAILABLE', 'SOURCE_OFFLINE', 'DELETED'].includes(input.sourceState)) + return rejected('SOURCE_UNAVAILABLE'); + if (input.action === 'OPEN_ON_DEVICE' && input.artifactDataMode !== 'Local') + return rejected('INVALID_ACTION'); + if (input.action === 'EXCERPT' && input.artifactDataMode === 'Local') + return rejected('LOCAL_CONTENT_LEAK'); + if (input.action === 'EXCERPT' && input.sourceState !== 'AVAILABLE') + return rejected('SOURCE_UNAVAILABLE'); + if ( + typeof input.authorizationEpoch !== 'number' || + !Number.isSafeInteger(input.authorizationEpoch) || + input.authorizationEpoch < 1 + ) + return rejected('INVALID_EPOCH'); const maxExcerptBytes = input.maxExcerptBytes ?? (input.action === 'EXCERPT' ? 512 : 0); - if (typeof maxExcerptBytes !== 'number' || !Number.isSafeInteger(maxExcerptBytes) || maxExcerptBytes < 0 || maxExcerptBytes > 4096) return rejected('INVALID_BYTES'); + if ( + typeof maxExcerptBytes !== 'number' || + !Number.isSafeInteger(maxExcerptBytes) || + maxExcerptBytes < 0 || + maxExcerptBytes > 4096 + ) + return rejected('INVALID_BYTES'); if (input.action !== 'EXCERPT' && maxExcerptBytes !== 0) return rejected('INVALID_BYTES'); - return accepted(Object.freeze({ - schemaVersion: EVIDENCE_GRANT_SCHEMA_VERSION_V1, - grantId, - evidenceId, - artifactVersionId, - tenantScope, - recipientDeviceId, - action: input.action as EvidenceGrantActionV1, - issuedAt, - expiresAt, - authorizationEpoch: input.authorizationEpoch, - maxExcerptBytes, - })); + return accepted( + Object.freeze({ + schemaVersion: EVIDENCE_GRANT_SCHEMA_VERSION_V1, + grantId, + evidenceId, + artifactVersionId, + tenantScope, + recipientDeviceId, + action: input.action as EvidenceGrantActionV1, + issuedAt, + expiresAt, + authorizationEpoch: input.authorizationEpoch, + maxExcerptBytes, + }), + ); } -export function evidenceGrantMatchesScopeV1(grant: EvidenceAccessGrantV1, scopeInput: unknown): boolean { +export function evidenceGrantMatchesScopeV1( + grant: EvidenceAccessGrantV1, + scopeInput: unknown, +): boolean { const candidate = scope(scopeInput); return candidate !== undefined && tenantScopesEqualV1(candidate, grant.tenantScope); } diff --git a/packages/domain/src/mapping/v1.ts b/packages/domain/src/mapping/v1.ts index efad6581..60990f02 100644 --- a/packages/domain/src/mapping/v1.ts +++ b/packages/domain/src/mapping/v1.ts @@ -79,7 +79,9 @@ function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { } function hash(input: unknown): string | undefined { - return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) ? input.toLowerCase() : undefined; + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; } function mappingStep(input: unknown): MappingStepV1 | MappingErrorCodeV1 { @@ -88,8 +90,21 @@ function mappingStep(input: unknown): MappingStepV1 | MappingErrorCodeV1 { const sourceFieldId = identifier(record['sourceFieldId']); const targetFieldId = identifier(record['targetFieldId']); const transform = record['transform']; - const lookupVersionId = record['lookupVersionId'] === undefined ? undefined : identifier(record['lookupVersionId']); - if (!sourceFieldId || !targetFieldId || !['IDENTITY', 'TRIM', 'LOWERCASE', 'UPPERCASE', 'PARSE_DECIMAL', 'PARSE_DATE', 'LOOKUP'].includes(transform as string)) + const lookupVersionId = + record['lookupVersionId'] === undefined ? undefined : identifier(record['lookupVersionId']); + if ( + !sourceFieldId || + !targetFieldId || + ![ + 'IDENTITY', + 'TRIM', + 'LOWERCASE', + 'UPPERCASE', + 'PARSE_DECIMAL', + 'PARSE_DATE', + 'LOOKUP', + ].includes(transform as string) + ) return 'INVALID_STEP'; if (record['lookupVersionId'] !== undefined && !lookupVersionId) return 'INVALID_IDENTIFIER'; if (transform === 'LOOKUP' && !lookupVersionId) return 'LOOKUP_REQUIRED'; @@ -124,32 +139,40 @@ export function createMappingDefinitionV1(input: { if (!datasetId || !versionId || !sourceSchemaVersionId || !targetSchemaVersionId) return rejected('INVALID_IDENTIFIER'); if (!tenantScope) return rejected('INVALID_SCOPE'); - if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) return rejected('INVALID_TIMESTAMP'); - if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) return rejected('INVALID_TIMESTAMP'); + if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) + return rejected('INVALID_TIMESTAMP'); + if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); if (!canonicalHash) return rejected('INVALID_HASH'); if (!Array.isArray(input.steps) || input.steps.length === 0 || input.steps.length > 512) return rejected('INVALID_STEP'); const parsedSteps = input.steps.map(mappingStep); if (parsedSteps.some((step): step is MappingErrorCodeV1 => typeof step === 'string')) - return rejected(parsedSteps.find((step): step is MappingErrorCodeV1 => typeof step === 'string') ?? 'INVALID_STEP'); + return rejected( + parsedSteps.find((step): step is MappingErrorCodeV1 => typeof step === 'string') ?? + 'INVALID_STEP', + ); const steps = parsedSteps as MappingStepV1[]; const targets = new Set(steps.map((step) => step.targetFieldId)); if (targets.size !== steps.length) return rejected('DUPLICATE_MAPPING'); const status = input.status ?? 'DRAFT'; - if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) return rejected('INVALID_STATE'); - return accepted(Object.freeze({ - schemaVersion: MAPPING_SCHEMA_VERSION_V1, - datasetId, - versionId, - tenantScope, - sourceSchemaVersionId, - targetSchemaVersionId, - steps: Object.freeze(steps), - status: status as MappingStatusV1, - createdAt, - ...(publishedAt ? { publishedAt } : {}), - canonicalHash, - })); + if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) + return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + schemaVersion: MAPPING_SCHEMA_VERSION_V1, + datasetId, + versionId, + tenantScope, + sourceSchemaVersionId, + targetSchemaVersionId, + steps: Object.freeze(steps), + status: status as MappingStatusV1, + createdAt, + ...(publishedAt ? { publishedAt } : {}), + canonicalHash, + }), + ); } export function publishMappingDefinitionV1( @@ -160,7 +183,15 @@ export function publishMappingDefinitionV1( const nextVersionId = identifier(nextVersionIdInput); const publishedAt = timestamp(publishedAtInput); if (!nextVersionId) return rejected('INVALID_IDENTIFIER'); - if (!publishedAt || Date.parse(publishedAt) < Date.parse(definition.createdAt)) return rejected('INVALID_TIMESTAMP'); + if (!publishedAt || Date.parse(publishedAt) < Date.parse(definition.createdAt)) + return rejected('INVALID_TIMESTAMP'); if (definition.status !== 'DRAFT') return rejected('INVALID_STATE'); - return accepted(Object.freeze({ ...definition, versionId: nextVersionId, status: 'PUBLISHED' as const, publishedAt })); + return accepted( + Object.freeze({ + ...definition, + versionId: nextVersionId, + status: 'PUBLISHED' as const, + publishedAt, + }), + ); } diff --git a/packages/domain/src/reference-entity/v1.ts b/packages/domain/src/reference-entity/v1.ts index bdc3f175..f4914f9d 100644 --- a/packages/domain/src/reference-entity/v1.ts +++ b/packages/domain/src/reference-entity/v1.ts @@ -134,10 +134,10 @@ export function createBusinessPartyVersionV1(input: { const aliasesInput = input.aliases === undefined ? [] : input.aliases; if (!Array.isArray(aliasesInput) || aliasesInput.length > 64) return rejected('INVALID_TEXT'); const aliases = aliasesInput.map((alias) => text(alias, 255)); - if (aliases.some((alias): alias is undefined => alias === undefined)) return rejected('INVALID_TEXT'); - const externalInput = input.externalIdentifiers === undefined ? [] : input.externalIdentifiers; - if (!Array.isArray(externalInput) || externalInput.length > 64) + if (aliases.some((alias): alias is undefined => alias === undefined)) return rejected('INVALID_TEXT'); + const externalInput = input.externalIdentifiers === undefined ? [] : input.externalIdentifiers; + if (!Array.isArray(externalInput) || externalInput.length > 64) return rejected('INVALID_TEXT'); const externalIdentifiers: ExternalIdentifierV1[] = []; for (const candidate of externalInput) { if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) @@ -152,7 +152,8 @@ export function createBusinessPartyVersionV1(input: { if (new Set(externalKeys).size !== externalKeys.length) return rejected('DUPLICATE_VALUE'); const status = input.status ?? 'ACTIVE'; const visibility = input.visibility ?? 'WORKSPACE'; - if (!['ACTIVE', 'INACTIVE', 'MERGED'].includes(status as string)) return rejected('INVALID_STATE'); + if (!['ACTIVE', 'INACTIVE', 'MERGED'].includes(status as string)) + return rejected('INVALID_STATE'); if (!['WORKSPACE', 'PROJECT'].includes(visibility as string)) return rejected('INVALID_STATE'); return accepted( Object.freeze({ diff --git a/packages/domain/src/rule-set/v1.ts b/packages/domain/src/rule-set/v1.ts index 521bd902..051754b1 100644 --- a/packages/domain/src/rule-set/v1.ts +++ b/packages/domain/src/rule-set/v1.ts @@ -80,7 +80,9 @@ function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { } function hash(input: unknown): string | undefined { - return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) ? input.toLowerCase() : undefined; + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; } function rule(input: unknown): QualityRuleV1 | RuleSetErrorCodeV1 { @@ -91,22 +93,46 @@ function rule(input: unknown): QualityRuleV1 | RuleSetErrorCodeV1 { const kind = record['kind']; const severity = record['severity']; const parameters = record['parameters'] ?? {}; - if (!ruleId || !fieldId || !['REQUIRED', 'TYPE', 'RANGE', 'UNIQUE', 'REFERENCE'].includes(kind as string)) return 'INVALID_RULE'; + if ( + !ruleId || + !fieldId || + !['REQUIRED', 'TYPE', 'RANGE', 'UNIQUE', 'REFERENCE'].includes(kind as string) + ) + return 'INVALID_RULE'; if (!['ERROR', 'WARNING'].includes(severity as string)) return 'INVALID_RULE'; - if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) return 'INVALID_PARAMETERS'; + if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) + return 'INVALID_PARAMETERS'; if (kind === 'TYPE') { - if (!['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes((parameters as Record)['expectedType'] as string)) return 'INVALID_PARAMETERS'; + if ( + !['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes( + (parameters as Record)['expectedType'] as string, + ) + ) + return 'INVALID_PARAMETERS'; } else if (kind === 'RANGE') { const range = parameters as Record; const minimum = range['minimum']; const maximum = range['maximum']; - if ((minimum !== undefined && (typeof minimum !== 'number' || !Number.isFinite(minimum))) || (maximum !== undefined && (typeof maximum !== 'number' || !Number.isFinite(maximum))) || (minimum === undefined && maximum === undefined) || (minimum !== undefined && maximum !== undefined && minimum > maximum)) return 'INVALID_PARAMETERS'; + if ( + (minimum !== undefined && (typeof minimum !== 'number' || !Number.isFinite(minimum))) || + (maximum !== undefined && (typeof maximum !== 'number' || !Number.isFinite(maximum))) || + (minimum === undefined && maximum === undefined) || + (minimum !== undefined && maximum !== undefined && minimum > maximum) + ) + return 'INVALID_PARAMETERS'; } else if (kind === 'REFERENCE') { - if (!identifier((parameters as Record)['referenceEntityVersionId'])) return 'INVALID_PARAMETERS'; + if (!identifier((parameters as Record)['referenceEntityVersionId'])) + return 'INVALID_PARAMETERS'; } else if (Object.keys(parameters as object).length > 0) { return 'INVALID_PARAMETERS'; } - return Object.freeze({ ruleId, fieldId, kind: kind as RuleKindV1, severity: severity as RuleSeverityV1, parameters: Object.freeze({ ...(parameters as Record) }) as RuleParametersV1 }); + return Object.freeze({ + ruleId, + fieldId, + kind: kind as RuleKindV1, + severity: severity as RuleSeverityV1, + parameters: Object.freeze({ ...(parameters as Record) }) as RuleParametersV1, + }); } export function createRuleSetDefinitionV1(input: { @@ -129,24 +155,61 @@ export function createRuleSetDefinitionV1(input: { const canonicalHash = hash(input.canonicalHash); if (!datasetId || !versionId || !schemaVersionId) return rejected('INVALID_IDENTIFIER'); if (!tenantScope) return rejected('INVALID_SCOPE'); - if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) return rejected('INVALID_TIMESTAMP'); - if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) return rejected('INVALID_TIMESTAMP'); + if (!createdAt || (input.publishedAt !== undefined && !publishedAt)) + return rejected('INVALID_TIMESTAMP'); + if (publishedAt && Date.parse(publishedAt) < Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); if (!canonicalHash) return rejected('INVALID_HASH'); - if (!Array.isArray(input.rules) || input.rules.length === 0 || input.rules.length > 512) return rejected('INVALID_RULE'); + if (!Array.isArray(input.rules) || input.rules.length === 0 || input.rules.length > 512) + return rejected('INVALID_RULE'); const parsedRules = input.rules.map(rule); - if (parsedRules.some((candidate): candidate is RuleSetErrorCodeV1 => typeof candidate === 'string')) return rejected(parsedRules.find((candidate): candidate is RuleSetErrorCodeV1 => typeof candidate === 'string') ?? 'INVALID_RULE'); + if ( + parsedRules.some((candidate): candidate is RuleSetErrorCodeV1 => typeof candidate === 'string') + ) + return rejected( + parsedRules.find( + (candidate): candidate is RuleSetErrorCodeV1 => typeof candidate === 'string', + ) ?? 'INVALID_RULE', + ); const rules = parsedRules as QualityRuleV1[]; - if (new Set(rules.map((candidate) => candidate.ruleId)).size !== rules.length) return rejected('DUPLICATE_RULE'); + if (new Set(rules.map((candidate) => candidate.ruleId)).size !== rules.length) + return rejected('DUPLICATE_RULE'); const status = input.status ?? 'DRAFT'; - if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) return rejected('INVALID_STATE'); - return accepted(Object.freeze({ schemaVersion: RULE_SET_SCHEMA_VERSION_V1, datasetId, versionId, tenantScope, schemaVersionId, rules: Object.freeze(rules), status: status as RuleSetStatusV1, createdAt, ...(publishedAt ? { publishedAt } : {}), canonicalHash })); + if (!['DRAFT', 'PUBLISHED', 'RETIRED'].includes(status as string)) + return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + schemaVersion: RULE_SET_SCHEMA_VERSION_V1, + datasetId, + versionId, + tenantScope, + schemaVersionId, + rules: Object.freeze(rules), + status: status as RuleSetStatusV1, + createdAt, + ...(publishedAt ? { publishedAt } : {}), + canonicalHash, + }), + ); } -export function publishRuleSetDefinitionV1(definition: RuleSetDefinitionV1, nextVersionIdInput: unknown, publishedAtInput: unknown): RuleSetResultV1 { +export function publishRuleSetDefinitionV1( + definition: RuleSetDefinitionV1, + nextVersionIdInput: unknown, + publishedAtInput: unknown, +): RuleSetResultV1 { const nextVersionId = identifier(nextVersionIdInput); const publishedAt = timestamp(publishedAtInput); if (!nextVersionId) return rejected('INVALID_IDENTIFIER'); - if (!publishedAt || Date.parse(publishedAt) < Date.parse(definition.createdAt)) return rejected('INVALID_TIMESTAMP'); + if (!publishedAt || Date.parse(publishedAt) < Date.parse(definition.createdAt)) + return rejected('INVALID_TIMESTAMP'); if (definition.status !== 'DRAFT') return rejected('INVALID_STATE'); - return accepted(Object.freeze({ ...definition, versionId: nextVersionId, status: 'PUBLISHED' as const, publishedAt })); + return accepted( + Object.freeze({ + ...definition, + versionId: nextVersionId, + status: 'PUBLISHED' as const, + publishedAt, + }), + ); } diff --git a/packages/domain/test/artifact-governance-v1.test.mjs b/packages/domain/test/artifact-governance-v1.test.mjs index e9114db5..bbe404c8 100644 --- a/packages/domain/test/artifact-governance-v1.test.mjs +++ b/packages/domain/test/artifact-governance-v1.test.mjs @@ -62,14 +62,20 @@ void test('[IAE-008] derived data mode cannot be wider than its least-permissive assert.equal(source.accepted, true); assert.equal(derived.accepted, true); if (!source.accepted || !derived.accepted) return; - assert.deepEqual(validateDerivedArtifactVersionV1({ derived: derived.value, sourceVersions: [source.value] }), { - accepted: false, - code: 'DATA_MODE_WIDENING', - }); - assert.deepEqual(validateDerivedArtifactVersionV1({ - derived: { ...derived.value, dataMode: 'Local' }, - sourceVersions: [source.value], - }), { accepted: true, value: true }); + assert.deepEqual( + validateDerivedArtifactVersionV1({ derived: derived.value, sourceVersions: [source.value] }), + { + accepted: false, + code: 'DATA_MODE_WIDENING', + }, + ); + assert.deepEqual( + validateDerivedArtifactVersionV1({ + derived: { ...derived.value, dataMode: 'Local' }, + sourceVersions: [source.value], + }), + { accepted: true, value: true }, + ); }); void test('[IAE-021] deletion eligibility is blocked by the strictest retention and governance condition', () => { diff --git a/packages/domain/test/artifact-v1.test.mjs b/packages/domain/test/artifact-v1.test.mjs index 02f03f24..b182283a 100644 --- a/packages/domain/test/artifact-v1.test.mjs +++ b/packages/domain/test/artifact-v1.test.mjs @@ -110,10 +110,7 @@ void test('[IAE-006] evidence coordinates are validated against exact source geo { accepted: false, code: 'COORDINATE_OUT_OF_BOUNDS' }, ); assert.deepEqual( - validateEvidenceCoordinateV1( - { kind: 'PAGE', page: 4 }, - { kind: 'PAGED', maxPage: 3 }, - ), + validateEvidenceCoordinateV1({ kind: 'PAGE', page: 4 }, { kind: 'PAGED', maxPage: 3 }), { accepted: false, code: 'COORDINATE_OUT_OF_BOUNDS' }, ); }); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 26fee4d8..7bcfdb56 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -51,11 +51,11 @@ assert.equal(aggregate.AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(permissions.PERMISSION_SCHEMA_VERSION_V1, 1); assert.equal(typeof tenantScope.parseTenantScopeV1, 'function'); assert.equal(typeof authorization.createScopedAuthorizationEvaluatorV1, 'function'); - assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); - assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); - assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); - assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); - assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); +assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); +assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); +assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); +assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); +assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); @@ -63,9 +63,9 @@ assert.equal(executionAttempt.EXECUTION_ATTEMPT_SCHEMA_VERSION_V1, 1); assert.equal(resultManifest.RESULT_MANIFEST_SCHEMA_VERSION_V1, 1); assert.equal(dispatch.DISPATCH_SCHEMA_VERSION_V1, 1); assert.equal(recipe.RECIPE_SCHEMA_VERSION_V1, 1); - assert.equal(finding.FINDING_SCHEMA_VERSION_V1, 1); - assert.equal(referenceEntity.REFERENCE_ENTITY_SCHEMA_VERSION_V1, 1); - assert.equal(mapping.MAPPING_SCHEMA_VERSION_V1, 1); - assert.equal(ruleSet.RULE_SET_SCHEMA_VERSION_V1, 1); - assert.equal(evidenceGrant.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); +assert.equal(finding.FINDING_SCHEMA_VERSION_V1, 1); +assert.equal(referenceEntity.REFERENCE_ENTITY_SCHEMA_VERSION_V1, 1); +assert.equal(mapping.MAPPING_SCHEMA_VERSION_V1, 1); +assert.equal(ruleSet.RULE_SET_SCHEMA_VERSION_V1, 1); +assert.equal(evidenceGrant.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/dataset-governance-v1.test.mjs b/packages/domain/test/dataset-governance-v1.test.mjs index 2094aba8..981c5928 100644 --- a/packages/domain/test/dataset-governance-v1.test.mjs +++ b/packages/domain/test/dataset-governance-v1.test.mjs @@ -19,7 +19,9 @@ const ids = { amountFieldId: '00000000-0000-4000-8000-000000000012', }; -function definition(fields = [{ fieldId: ids.amountFieldId, name: 'amount', type: 'DECIMAL', nullable: true }]) { +function definition( + fields = [{ fieldId: ids.amountFieldId, name: 'amount', type: 'DECIMAL', nullable: true }], +) { return createGovernedDatasetDefinitionV1({ datasetId: ids.datasetId, versionId: ids.versionId, diff --git a/packages/domain/test/evidence-grant-v1.test.mjs b/packages/domain/test/evidence-grant-v1.test.mjs index 87e31082..75886615 100644 --- a/packages/domain/test/evidence-grant-v1.test.mjs +++ b/packages/domain/test/evidence-grant-v1.test.mjs @@ -3,9 +3,22 @@ import test from 'node:test'; import { createEvidenceAccessGrantV1 } from '../dist/evidence-grant/v1.js'; -const scope = { scopeType: 'workspace', organizationId: '00000000-0000-4000-8000-000000000001', workspaceId: '00000000-0000-4000-8000-000000000002' }; +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; const base = { - grantId: '00000000-0000-4000-8000-000000000010', evidenceId: '00000000-0000-4000-8000-000000000011', artifactVersionId: '00000000-0000-4000-8000-000000000012', tenantScope: scope, recipientDeviceId: '00000000-0000-4000-8000-000000000013', issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T00:05:00.000Z', authorizationEpoch: 2, artifactDataMode: 'Hybrid', sourceState: 'AVAILABLE', + grantId: '00000000-0000-4000-8000-000000000010', + evidenceId: '00000000-0000-4000-8000-000000000011', + artifactVersionId: '00000000-0000-4000-8000-000000000012', + tenantScope: scope, + recipientDeviceId: '00000000-0000-4000-8000-000000000013', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T00:05:00.000Z', + authorizationEpoch: 2, + artifactDataMode: 'Hybrid', + sourceState: 'AVAILABLE', }; void test('[IAE-005] grants are short-lived and bind an action to a device epoch', () => { @@ -19,13 +32,30 @@ void test('[IAE-005] grants are short-lived and bind an action to a device epoch }); void test('[IAE-006] Local evidence cannot create excerpt or cloud-open grants', () => { - assert.deepEqual(createEvidenceAccessGrantV1({ ...base, action: 'EXCERPT', artifactDataMode: 'Local' }), { accepted: false, code: 'LOCAL_CONTENT_LEAK' }); - const local = createEvidenceAccessGrantV1({ ...base, action: 'OPEN_ON_DEVICE', artifactDataMode: 'Local' }); + assert.deepEqual( + createEvidenceAccessGrantV1({ ...base, action: 'EXCERPT', artifactDataMode: 'Local' }), + { accepted: false, code: 'LOCAL_CONTENT_LEAK' }, + ); + const local = createEvidenceAccessGrantV1({ + ...base, + action: 'OPEN_ON_DEVICE', + artifactDataMode: 'Local', + }); assert.equal(local.accepted, true); if (local.accepted) assert.equal(local.value.action, 'OPEN_ON_DEVICE'); }); void test('[IAE-005] grants reject long expiry and unavailable excerpts', () => { - assert.deepEqual(createEvidenceAccessGrantV1({ ...base, action: 'COORDINATE', expiresAt: '2026-01-01T00:16:00.000Z' }), { accepted: false, code: 'EXPIRY_TOO_LONG' }); - assert.deepEqual(createEvidenceAccessGrantV1({ ...base, action: 'EXCERPT', sourceState: 'SOURCE_OFFLINE' }), { accepted: false, code: 'SOURCE_UNAVAILABLE' }); + assert.deepEqual( + createEvidenceAccessGrantV1({ + ...base, + action: 'COORDINATE', + expiresAt: '2026-01-01T00:16:00.000Z', + }), + { accepted: false, code: 'EXPIRY_TOO_LONG' }, + ); + assert.deepEqual( + createEvidenceAccessGrantV1({ ...base, action: 'EXCERPT', sourceState: 'SOURCE_OFFLINE' }), + { accepted: false, code: 'SOURCE_UNAVAILABLE' }, + ); }); diff --git a/packages/domain/test/mapping-rule-set-v1.test.mjs b/packages/domain/test/mapping-rule-set-v1.test.mjs index 63fd7cfc..8ce1f0ca 100644 --- a/packages/domain/test/mapping-rule-set-v1.test.mjs +++ b/packages/domain/test/mapping-rule-set-v1.test.mjs @@ -17,42 +17,115 @@ void test('[DSM-007, DSM-008] mappings are bounded, declarative, and publish as tenantScope: scope, sourceSchemaVersionId: '00000000-0000-4000-8000-000000000012', targetSchemaVersionId: '00000000-0000-4000-8000-000000000013', - steps: [{ sourceFieldId: '00000000-0000-4000-8000-000000000014', targetFieldId: '00000000-0000-4000-8000-000000000015', transform: 'LOOKUP', lookupVersionId: '00000000-0000-4000-8000-000000000016' }], + steps: [ + { + sourceFieldId: '00000000-0000-4000-8000-000000000014', + targetFieldId: '00000000-0000-4000-8000-000000000015', + transform: 'LOOKUP', + lookupVersionId: '00000000-0000-4000-8000-000000000016', + }, + ], createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'a'.repeat(64), }; const created = createMappingDefinitionV1(input); assert.equal(created.accepted, true); if (!created.accepted) return; - const published = publishMappingDefinitionV1(created.value, '00000000-0000-4000-8000-000000000017', '2026-01-01T00:01:00.000Z'); + const published = publishMappingDefinitionV1( + created.value, + '00000000-0000-4000-8000-000000000017', + '2026-01-01T00:01:00.000Z', + ); assert.equal(published.accepted, true); - assert.deepEqual(createMappingDefinitionV1({ ...input, steps: [{ ...input.steps[0], transform: 'LOOKUP' }] }), created); + assert.deepEqual( + createMappingDefinitionV1({ ...input, steps: [{ ...input.steps[0], transform: 'LOOKUP' }] }), + created, + ); }); void test('[DSM-007] mappings reject duplicate targets and executable transforms', () => { const base = { - datasetId: '00000000-0000-4000-8000-000000000020', versionId: '00000000-0000-4000-8000-000000000021', tenantScope: scope, - sourceSchemaVersionId: '00000000-0000-4000-8000-000000000022', targetSchemaVersionId: '00000000-0000-4000-8000-000000000023', createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'b'.repeat(64), + datasetId: '00000000-0000-4000-8000-000000000020', + versionId: '00000000-0000-4000-8000-000000000021', + tenantScope: scope, + sourceSchemaVersionId: '00000000-0000-4000-8000-000000000022', + targetSchemaVersionId: '00000000-0000-4000-8000-000000000023', + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), }; - assert.deepEqual(createMappingDefinitionV1({ ...base, steps: [ - { sourceFieldId: '00000000-0000-4000-8000-000000000024', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'IDENTITY' }, - { sourceFieldId: '00000000-0000-4000-8000-000000000026', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'IDENTITY' }, - ] }), { accepted: false, code: 'DUPLICATE_MAPPING' }); - assert.deepEqual(createMappingDefinitionV1({ ...base, steps: [{ sourceFieldId: '00000000-0000-4000-8000-000000000024', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'EXECUTE_SCRIPT' }] }), { accepted: false, code: 'INVALID_STEP' }); + assert.deepEqual( + createMappingDefinitionV1({ + ...base, + steps: [ + { + sourceFieldId: '00000000-0000-4000-8000-000000000024', + targetFieldId: '00000000-0000-4000-8000-000000000025', + transform: 'IDENTITY', + }, + { + sourceFieldId: '00000000-0000-4000-8000-000000000026', + targetFieldId: '00000000-0000-4000-8000-000000000025', + transform: 'IDENTITY', + }, + ], + }), + { accepted: false, code: 'DUPLICATE_MAPPING' }, + ); + assert.deepEqual( + createMappingDefinitionV1({ + ...base, + steps: [ + { + sourceFieldId: '00000000-0000-4000-8000-000000000024', + targetFieldId: '00000000-0000-4000-8000-000000000025', + transform: 'EXECUTE_SCRIPT', + }, + ], + }), + { accepted: false, code: 'INVALID_STEP' }, + ); }); void test('[DSM-009, DSM-010, DSM-011] rule sets accept only typed deterministic parameters', () => { const input = { - datasetId: '00000000-0000-4000-8000-000000000030', versionId: '00000000-0000-4000-8000-000000000031', tenantScope: scope, - schemaVersionId: '00000000-0000-4000-8000-000000000032', createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'c'.repeat(64), + datasetId: '00000000-0000-4000-8000-000000000030', + versionId: '00000000-0000-4000-8000-000000000031', + tenantScope: scope, + schemaVersionId: '00000000-0000-4000-8000-000000000032', + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'c'.repeat(64), rules: [ - { ruleId: '00000000-0000-4000-8000-000000000033', fieldId: '00000000-0000-4000-8000-000000000034', kind: 'REQUIRED', severity: 'ERROR' }, - { ruleId: '00000000-0000-4000-8000-000000000035', fieldId: '00000000-0000-4000-8000-000000000036', kind: 'RANGE', severity: 'WARNING', parameters: { minimum: 0, maximum: 100 } }, + { + ruleId: '00000000-0000-4000-8000-000000000033', + fieldId: '00000000-0000-4000-8000-000000000034', + kind: 'REQUIRED', + severity: 'ERROR', + }, + { + ruleId: '00000000-0000-4000-8000-000000000035', + fieldId: '00000000-0000-4000-8000-000000000036', + kind: 'RANGE', + severity: 'WARNING', + parameters: { minimum: 0, maximum: 100 }, + }, ], }; const created = createRuleSetDefinitionV1(input); assert.equal(created.accepted, true); if (!created.accepted) return; - assert.equal(publishRuleSetDefinitionV1(created.value, '00000000-0000-4000-8000-000000000037', '2026-01-01T00:01:00.000Z').accepted, true); - assert.deepEqual(createRuleSetDefinitionV1({ ...input, rules: [{ ...input.rules[0], parameters: { script: 'drop table' } }] }), { accepted: false, code: 'INVALID_PARAMETERS' }); + assert.equal( + publishRuleSetDefinitionV1( + created.value, + '00000000-0000-4000-8000-000000000037', + '2026-01-01T00:01:00.000Z', + ).accepted, + true, + ); + assert.deepEqual( + createRuleSetDefinitionV1({ + ...input, + rules: [{ ...input.rules[0], parameters: { script: 'drop table' } }], + }), + { accepted: false, code: 'INVALID_PARAMETERS' }, + ); }); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 45821a0a..2d35fe9c 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -19,19 +19,19 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './mfa/v1', './device-authorization/v1', './data-mode/v1', - './artifact/v1', - './artifact-intake/v1', - './artifact-governance/v1', - './dataset/v1', - './dataset-governance/v1', + './artifact/v1', + './artifact-intake/v1', + './artifact-governance/v1', + './dataset/v1', + './dataset-governance/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', './result-manifest/v1', './dispatch/v1', './recipe/v1', - './finding/v1', - './reference-entity/v1', + './finding/v1', + './reference-entity/v1', './mapping/v1', './rule-set/v1', './evidence-grant/v1', diff --git a/packages/domain/test/reference-entity-v1.test.mjs b/packages/domain/test/reference-entity-v1.test.mjs index d558f7b8..ac33089b 100644 --- a/packages/domain/test/reference-entity-v1.test.mjs +++ b/packages/domain/test/reference-entity-v1.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createBusinessPartyVersionV1, mergeBusinessPartyVersionsV1 } from '../dist/reference-entity/v1.js'; +import { + createBusinessPartyVersionV1, + mergeBusinessPartyVersionsV1, +} from '../dist/reference-entity/v1.js'; const scope = { scopeType: 'workspace', @@ -28,10 +31,10 @@ void test('[DSM-025, DSM-026] business-party versions are workspace-scoped and c if (!result.accepted) return; assert.equal(result.value.displayName, 'Công ty Ánh Dương'); assert.equal(Object.isFrozen(result.value.externalIdentifiers[0]), true); - assert.deepEqual( - createBusinessPartyVersionV1({ ...base, roles: [] }), - { accepted: false, code: 'INVALID_ROLE' }, - ); + assert.deepEqual(createBusinessPartyVersionV1({ ...base, roles: [] }), { + accepted: false, + code: 'INVALID_ROLE', + }); }); void test('[DSM-027] merge creates an explicit redirect without retargeting history', () => { diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index ad1a717b..e1ed87d0 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1624,14 +1624,7 @@ "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, - "required": [ - "datasetId", - "versionId", - "name", - "fields", - "createdAt", - "canonicalHash" - ] + "required": ["datasetId", "versionId", "name", "fields", "createdAt", "canonicalHash"] }, "MappingStepDto": { "type": "object", @@ -1685,13 +1678,7 @@ "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, - "required": [ - "versionId", - "schemaVersionId", - "rules", - "createdAt", - "canonicalHash" - ] + "required": ["versionId", "schemaVersionId", "rules", "createdAt", "canonicalHash"] }, "CreateReferenceEntityDto": { "type": "object", @@ -1714,14 +1701,7 @@ "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "createdAt": { "type": "string", "format": "date-time" } }, - "required": [ - "entityId", - "versionId", - "displayName", - "roles", - "canonicalHash", - "createdAt" - ] + "required": ["entityId", "versionId", "displayName", "roles", "canonicalHash", "createdAt"] }, "MergeReferenceEntityDto": { "type": "object", diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index fe9d10c9..cc0137fb 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -5,7 +5,10 @@ import { SystemModule, type SystemModuleOptions } from './features/system/system import { IaeModule, type IaeModuleOptions } from './features/iae/iae.module.js'; import { DsmModule, type DsmModuleOptions } from './features/dsm/dsm.module.js'; -export type AppModuleOptions = SystemModuleOptions & IamModuleOptions & IaeModuleOptions & DsmModuleOptions; +export type AppModuleOptions = SystemModuleOptions & + IamModuleOptions & + IaeModuleOptions & + DsmModuleOptions; @Module({}) export class AppModule { diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index fc7be51f..59ae3983 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -20,7 +20,10 @@ export interface ApiApplication { readonly openApi: OpenAPIObject | object; } -export interface ApiApplicationOptions extends IamModuleOptions, IaeModuleOptions, DsmModuleOptions { +export interface ApiApplicationOptions + extends IamModuleOptions, + IaeModuleOptions, + DsmModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; } diff --git a/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts index 6814bab9..474bb1db 100644 --- a/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts @@ -67,7 +67,8 @@ export class InMemoryGovernedDatasetRepositoryAdapter implements GovernedDataset return [...this.definitions.values()] .filter( (definition) => - definition.datasetId === datasetId && visible(context.tenantScope, definition.tenantScope), + definition.datasetId === datasetId && + visible(context.tenantScope, definition.tenantScope), ) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map(clone); diff --git a/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts index 62cd30cb..409237d5 100644 --- a/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts @@ -37,28 +37,49 @@ export class InMemoryMappingRepositoryAdapter implements MappingRepositoryPortV1 this.definitions.set(definition.versionId, clone(definition)); } - public async find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise { + public async find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise { await Promise.resolve(); const definition = this.definitions.get(versionId); - return definition && visible(context.tenantScope, definition.tenantScope) ? clone(definition) : undefined; + return definition && visible(context.tenantScope, definition.tenantScope) + ? clone(definition) + : undefined; } - public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { + public async list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise { await Promise.resolve(); return [...this.definitions.values()] - .filter((definition) => definition.datasetId === datasetId && visible(context.tenantScope, definition.tenantScope)) + .filter( + (definition) => + definition.datasetId === datasetId && + visible(context.tenantScope, definition.tenantScope), + ) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map(clone); } - public async withTransaction(context: IamTenantContextV1, work: (transaction: MappingTransactionPortV1) => Promise): Promise { + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: MappingTransactionPortV1) => Promise, + ): Promise { let release!: () => void; const previous = this.transactionTail; - this.transactionTail = new Promise((resolve) => { release = resolve; }); + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); await previous; const before = new Map(this.definitions); try { - return await work({ save: this.save.bind(this), find: this.find.bind(this), list: this.list.bind(this) }); + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); } catch (error) { this.definitions = before; throw error; diff --git a/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts index 58dd76e4..2d37d19e 100644 --- a/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts @@ -22,7 +22,9 @@ function cloneVersion(version: BusinessPartyVersionV1): BusinessPartyVersionV1 { tenantScope: Object.freeze({ ...version.tenantScope }), roles: Object.freeze([...version.roles]), aliases: Object.freeze([...version.aliases]), - externalIdentifiers: Object.freeze(version.externalIdentifiers.map((item) => Object.freeze({ ...item }))), + externalIdentifiers: Object.freeze( + version.externalIdentifiers.map((item) => Object.freeze({ ...item })), + ), }); } @@ -35,63 +37,117 @@ export class InMemoryReferenceEntityRepositoryAdapter implements ReferenceEntity private resolutions = new Map(); private transactionTail: Promise = Promise.resolve(); - public async saveVersion(context: IamTenantContextV1, version: BusinessPartyVersionV1): Promise { + public async saveVersion( + context: IamTenantContextV1, + version: BusinessPartyVersionV1, + ): Promise { await Promise.resolve(); - if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); const existing = this.versions.get(version.versionId); - if (existing && JSON.stringify(existing) !== JSON.stringify(version)) throw new Error('DSM_IMMUTABLE_REFERENCE_VERSION'); + if (existing && JSON.stringify(existing) !== JSON.stringify(version)) + throw new Error('DSM_IMMUTABLE_REFERENCE_VERSION'); this.versions.set(version.versionId, cloneVersion(version)); } - public async findVersion(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise { + public async findVersion( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise { await Promise.resolve(); const version = this.versions.get(versionId); - return version && visible(context.tenantScope, version.tenantScope) ? cloneVersion(version) : undefined; + return version && visible(context.tenantScope, version.tenantScope) + ? cloneVersion(version) + : undefined; } - public async findLatest(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + public async findLatest( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise { const versions = await this.listVersions(context, entityId); return versions.at(-1); } - public async listVersions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + public async listVersions( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise { await Promise.resolve(); return [...this.versions.values()] - .filter((version) => version.entityId === entityId && visible(context.tenantScope, version.tenantScope)) + .filter( + (version) => + version.entityId === entityId && visible(context.tenantScope, version.tenantScope), + ) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map(cloneVersion); } - public async saveResolution(context: IamTenantContextV1, resolution: BusinessPartyResolutionV1): Promise { + public async saveResolution( + context: IamTenantContextV1, + resolution: BusinessPartyResolutionV1, + ): Promise { await Promise.resolve(); - const source = [...this.versions.values()].find((candidate) => candidate.entityId === resolution.sourceEntityId); - const target = [...this.versions.values()].find((candidate) => candidate.entityId === resolution.targetEntityId); - if (!source || !target || !visible(context.tenantScope, source.tenantScope) || !visible(context.tenantScope, target.tenantScope)) throw new Error('DSM_REFERENCE_ENTITY_NOT_FOUND'); + const source = [...this.versions.values()].find( + (candidate) => candidate.entityId === resolution.sourceEntityId, + ); + const target = [...this.versions.values()].find( + (candidate) => candidate.entityId === resolution.targetEntityId, + ); + if ( + !source || + !target || + !visible(context.tenantScope, source.tenantScope) || + !visible(context.tenantScope, target.tenantScope) + ) + throw new Error('DSM_REFERENCE_ENTITY_NOT_FOUND'); const existing = this.resolutions.get(resolution.resolutionId); - if (existing && JSON.stringify(existing) !== JSON.stringify(resolution)) throw new Error('DSM_IMMUTABLE_REFERENCE_RESOLUTION'); + if (existing && JSON.stringify(existing) !== JSON.stringify(resolution)) + throw new Error('DSM_IMMUTABLE_REFERENCE_RESOLUTION'); this.resolutions.set(resolution.resolutionId, cloneResolution(resolution)); } - public async listResolutions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { + public async listResolutions( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise { await Promise.resolve(); return [...this.resolutions.values()] .filter((resolution) => { - const source = [...this.versions.values()].find((candidate) => candidate.entityId === resolution.sourceEntityId); - return (resolution.sourceEntityId === entityId || resolution.targetEntityId === entityId) && source !== undefined && visible(context.tenantScope, source.tenantScope); + const source = [...this.versions.values()].find( + (candidate) => candidate.entityId === resolution.sourceEntityId, + ); + return ( + (resolution.sourceEntityId === entityId || resolution.targetEntityId === entityId) && + source !== undefined && + visible(context.tenantScope, source.tenantScope) + ); }) .sort((left, right) => left.resolvedAt.localeCompare(right.resolvedAt)) .map(cloneResolution); } - public async withTransaction(context: IamTenantContextV1, work: (transaction: ReferenceEntityTransactionPortV1) => Promise): Promise { + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ReferenceEntityTransactionPortV1) => Promise, + ): Promise { let release!: () => void; const previous = this.transactionTail; - this.transactionTail = new Promise((resolve) => { release = resolve; }); + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); await previous; const beforeVersions = new Map(this.versions); const beforeResolutions = new Map(this.resolutions); try { - return await work({ saveVersion: this.saveVersion.bind(this), findVersion: this.findVersion.bind(this), findLatest: this.findLatest.bind(this), listVersions: this.listVersions.bind(this), saveResolution: this.saveResolution.bind(this), listResolutions: this.listResolutions.bind(this) }); + return await work({ + saveVersion: this.saveVersion.bind(this), + findVersion: this.findVersion.bind(this), + findLatest: this.findLatest.bind(this), + listVersions: this.listVersions.bind(this), + saveResolution: this.saveResolution.bind(this), + listResolutions: this.listResolutions.bind(this), + }); } catch (error) { this.versions = beforeVersions; this.resolutions = beforeResolutions; diff --git a/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts index e4b4d5a6..043a0240 100644 --- a/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts @@ -19,7 +19,11 @@ function clone(definition: RuleSetDefinitionV1): RuleSetDefinitionV1 { return Object.freeze({ ...definition, tenantScope: Object.freeze({ ...definition.tenantScope }), - rules: Object.freeze(definition.rules.map((rule) => Object.freeze({ ...rule, parameters: Object.freeze({ ...rule.parameters }) }))), + rules: Object.freeze( + definition.rules.map((rule) => + Object.freeze({ ...rule, parameters: Object.freeze({ ...rule.parameters }) }), + ), + ), }); } @@ -29,34 +33,57 @@ export class InMemoryRuleSetRepositoryAdapter implements RuleSetRepositoryPortV1 public async save(context: IamTenantContextV1, definition: RuleSetDefinitionV1): Promise { await Promise.resolve(); - if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + if (!tenantScopeContainsV1(context.tenantScope, definition.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); const existing = this.definitions.get(definition.versionId); - if (existing && JSON.stringify(existing) !== JSON.stringify(definition)) throw new Error('DSM_IMMUTABLE_RULE_SET'); + if (existing && JSON.stringify(existing) !== JSON.stringify(definition)) + throw new Error('DSM_IMMUTABLE_RULE_SET'); this.definitions.set(definition.versionId, clone(definition)); } - public async find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise { + public async find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise { await Promise.resolve(); const definition = this.definitions.get(versionId); - return definition && visible(context.tenantScope, definition.tenantScope) ? clone(definition) : undefined; + return definition && visible(context.tenantScope, definition.tenantScope) + ? clone(definition) + : undefined; } - public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { + public async list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise { await Promise.resolve(); return [...this.definitions.values()] - .filter((definition) => definition.datasetId === datasetId && visible(context.tenantScope, definition.tenantScope)) + .filter( + (definition) => + definition.datasetId === datasetId && + visible(context.tenantScope, definition.tenantScope), + ) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map(clone); } - public async withTransaction(context: IamTenantContextV1, work: (transaction: RuleSetTransactionPortV1) => Promise): Promise { + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: RuleSetTransactionPortV1) => Promise, + ): Promise { let release!: () => void; const previous = this.transactionTail; - this.transactionTail = new Promise((resolve) => { release = resolve; }); + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); await previous; const before = new Map(this.definitions); try { - return await work({ save: this.save.bind(this), find: this.find.bind(this), list: this.list.bind(this) }); + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); } catch (error) { this.definitions = before; throw error; diff --git a/services/api/src/features/dsm/api/governed-dataset.controller.ts b/services/api/src/features/dsm/api/governed-dataset.controller.ts index 25d57558..976d8e03 100644 --- a/services/api/src/features/dsm/api/governed-dataset.controller.ts +++ b/services/api/src/features/dsm/api/governed-dataset.controller.ts @@ -41,7 +41,10 @@ export class GovernedDatasetController { @Get(':datasetId/versions') @ApiOperation({ summary: 'List governed dataset versions visible to the caller' }) - async list(@Req() request: unknown, @Param('datasetId') datasetIdInput: string): Promise { + async list( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + ): Promise { const context = await this.requestContext.resolve(request); const datasetId = parseStableIdentifierV1(datasetIdInput); if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; 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 7f7fa1a2..f5152f27 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,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsIn, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; export class GovernedDatasetFieldDto { @ApiProperty({ format: 'uuid' }) diff --git a/services/api/src/features/dsm/api/mapping.controller.ts b/services/api/src/features/dsm/api/mapping.controller.ts index 782c2aee..bc142d38 100644 --- a/services/api/src/features/dsm/api/mapping.controller.ts +++ b/services/api/src/features/dsm/api/mapping.controller.ts @@ -2,10 +2,16 @@ import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common' import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; -import { MAPPING_REPOSITORY_PORT, type MappingRepositoryPortV1 } from '../application/mapping-repository.port.js'; +import { + MAPPING_REPOSITORY_PORT, + type MappingRepositoryPortV1, +} from '../application/mapping-repository.port.js'; import { MappingService } from '../application/mapping.service.js'; import { CreateMappingDto } from './mapping.dto.js'; -import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; @ApiTags('datasets') @ApiBearerAuth() @@ -23,16 +29,27 @@ export class MappingController { @Post() @ApiOperation({ summary: 'Create an immutable mapping definition draft' }) @ApiBody({ type: CreateMappingDto }) - async create(@Req() request: unknown, @Param('datasetId') datasetIdInput: string, @Body() input: CreateMappingDto): Promise { + async create( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Body() input: CreateMappingDto, + ): Promise { const context = await this.requestContext.resolve(request); const datasetId = parseStableIdentifierV1(datasetIdInput); if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; - return this.mappings.create(context, { ...input, datasetId: datasetId.value, tenantScope: context.tenantScope }); + return this.mappings.create(context, { + ...input, + datasetId: datasetId.value, + tenantScope: context.tenantScope, + }); } @Get() @ApiOperation({ summary: 'List immutable mapping versions' }) - async list(@Req() request: unknown, @Param('datasetId') datasetIdInput: string): Promise { + async list( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + ): Promise { const context = await this.requestContext.resolve(request); const datasetId = parseStableIdentifierV1(datasetIdInput); if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; diff --git a/services/api/src/features/dsm/api/mapping.dto.ts b/services/api/src/features/dsm/api/mapping.dto.ts index 0024a3bf..4b087f02 100644 --- a/services/api/src/features/dsm/api/mapping.dto.ts +++ b/services/api/src/features/dsm/api/mapping.dto.ts @@ -1,6 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsIn, IsISO8601, IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested } from 'class-validator'; +import { + IsArray, + IsIn, + IsISO8601, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; export class MappingStepDto { @ApiProperty({ format: 'uuid' }) @@ -11,9 +22,18 @@ export class MappingStepDto { @IsUUID() targetFieldId!: string; - @ApiProperty({ enum: ['IDENTITY', 'TRIM', 'LOWERCASE', 'UPPERCASE', 'PARSE_DECIMAL', 'PARSE_DATE', 'LOOKUP'] }) + @ApiProperty({ + enum: ['IDENTITY', 'TRIM', 'LOWERCASE', 'UPPERCASE', 'PARSE_DECIMAL', 'PARSE_DATE', 'LOOKUP'], + }) @IsIn(['IDENTITY', 'TRIM', 'LOWERCASE', 'UPPERCASE', 'PARSE_DECIMAL', 'PARSE_DATE', 'LOOKUP']) - transform!: 'IDENTITY' | 'TRIM' | 'LOWERCASE' | 'UPPERCASE' | 'PARSE_DECIMAL' | 'PARSE_DATE' | 'LOOKUP'; + transform!: + | 'IDENTITY' + | 'TRIM' + | 'LOWERCASE' + | 'UPPERCASE' + | 'PARSE_DECIMAL' + | 'PARSE_DATE' + | 'LOOKUP'; @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() diff --git a/services/api/src/features/dsm/api/reference-entity.controller.ts b/services/api/src/features/dsm/api/reference-entity.controller.ts index 56d39fa0..1c6fcbfc 100644 --- a/services/api/src/features/dsm/api/reference-entity.controller.ts +++ b/services/api/src/features/dsm/api/reference-entity.controller.ts @@ -2,10 +2,16 @@ import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common' import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; -import { REFERENCE_ENTITY_REPOSITORY_PORT, type ReferenceEntityRepositoryPortV1 } from '../application/reference-entity-repository.port.js'; +import { + REFERENCE_ENTITY_REPOSITORY_PORT, + type ReferenceEntityRepositoryPortV1, +} from '../application/reference-entity-repository.port.js'; import { ReferenceEntityService } from '../application/reference-entity.service.js'; import { CreateReferenceEntityDto, MergeReferenceEntityDto } from './reference-entity.dto.js'; -import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; @ApiTags('reference-entities') @ApiBearerAuth() diff --git a/services/api/src/features/dsm/api/reference-entity.dto.ts b/services/api/src/features/dsm/api/reference-entity.dto.ts index 59c0526d..c43721cf 100644 --- a/services/api/src/features/dsm/api/reference-entity.dto.ts +++ b/services/api/src/features/dsm/api/reference-entity.dto.ts @@ -1,5 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + IsArray, + IsIn, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; export class CreateReferenceEntityDto { @ApiProperty({ format: 'uuid' }) diff --git a/services/api/src/features/dsm/api/rule-set.controller.ts b/services/api/src/features/dsm/api/rule-set.controller.ts index 3b83dc76..dcf3187e 100644 --- a/services/api/src/features/dsm/api/rule-set.controller.ts +++ b/services/api/src/features/dsm/api/rule-set.controller.ts @@ -2,10 +2,16 @@ import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common' import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; -import { RULE_SET_REPOSITORY_PORT, type RuleSetRepositoryPortV1 } from '../application/rule-set-repository.port.js'; +import { + RULE_SET_REPOSITORY_PORT, + type RuleSetRepositoryPortV1, +} from '../application/rule-set-repository.port.js'; import { RuleSetService } from '../application/rule-set.service.js'; import { CreateRuleSetDto } from './mapping.dto.js'; -import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; @ApiTags('datasets') @ApiBearerAuth() @@ -23,16 +29,27 @@ export class RuleSetController { @Post() @ApiOperation({ summary: 'Create an immutable quality rule-set draft' }) @ApiBody({ type: CreateRuleSetDto }) - async create(@Req() request: unknown, @Param('datasetId') datasetIdInput: string, @Body() input: CreateRuleSetDto): Promise { + async create( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Body() input: CreateRuleSetDto, + ): Promise { const context = await this.requestContext.resolve(request); const datasetId = parseStableIdentifierV1(datasetIdInput); if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; - return this.ruleSets.create(context, { ...input, datasetId: datasetId.value, tenantScope: context.tenantScope }); + return this.ruleSets.create(context, { + ...input, + datasetId: datasetId.value, + tenantScope: context.tenantScope, + }); } @Get() @ApiOperation({ summary: 'List immutable quality rule-set versions' }) - async list(@Req() request: unknown, @Param('datasetId') datasetIdInput: string): Promise { + async list( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + ): Promise { const context = await this.requestContext.resolve(request); const datasetId = parseStableIdentifierV1(datasetIdInput); if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; diff --git a/services/api/src/features/dsm/application/governed-dataset.service.ts b/services/api/src/features/dsm/application/governed-dataset.service.ts index 14363e3e..7ec1903b 100644 --- a/services/api/src/features/dsm/application/governed-dataset.service.ts +++ b/services/api/src/features/dsm/application/governed-dataset.service.ts @@ -45,7 +45,11 @@ export class GovernedDatasetService { return this.repository.withTransaction(context, async (transaction) => { const current = await transaction.find(context, versionId); if (!current) return Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); - const published = publishGovernedDatasetDefinitionV1(current, nextVersionIdInput, publishedAt); + const published = publishGovernedDatasetDefinitionV1( + current, + nextVersionIdInput, + publishedAt, + ); if (!published.accepted) return published; await transaction.save(context, published.value); return published; @@ -60,7 +64,8 @@ export class GovernedDatasetService { return this.repository.withTransaction(context, async (transaction) => { const previous = await transaction.find(context, previousVersionId); const next = await transaction.find(context, nextVersionId); - if (!previous || !next) return Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); + if (!previous || !next) + return Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); return compareGovernedSchemaCompatibilityV1(previous, next); }); } @@ -69,6 +74,8 @@ export class GovernedDatasetService { context: IamTenantContextV1, datasetId: StableIdentifierV1, ): Promise { - return this.repository.withTransaction(context, (transaction) => transaction.list(context, datasetId)); + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetId), + ); } } diff --git a/services/api/src/features/dsm/application/mapping-repository.port.ts b/services/api/src/features/dsm/application/mapping-repository.port.ts index 1a745fa6..96704fa4 100644 --- a/services/api/src/features/dsm/application/mapping-repository.port.ts +++ b/services/api/src/features/dsm/application/mapping-repository.port.ts @@ -7,10 +7,19 @@ export const MAPPING_REPOSITORY_PORT = Symbol('MAPPING_REPOSITORY_PORT'); export interface MappingTransactionPortV1 { save(context: IamTenantContextV1, definition: MappingDefinitionV1): Promise; - find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise; - list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise; + find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise; + list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise; } export interface MappingRepositoryPortV1 extends MappingTransactionPortV1 { - withTransaction(context: IamTenantContextV1, work: (transaction: MappingTransactionPortV1) => Promise): Promise; + withTransaction( + context: IamTenantContextV1, + work: (transaction: MappingTransactionPortV1) => Promise, + ): Promise; } diff --git a/services/api/src/features/dsm/application/mapping.service.ts b/services/api/src/features/dsm/application/mapping.service.ts index 60e91021..282ea122 100644 --- a/services/api/src/features/dsm/application/mapping.service.ts +++ b/services/api/src/features/dsm/application/mapping.service.ts @@ -10,12 +10,17 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js import type { MappingRepositoryPortV1 } from './mapping-repository.port.js'; export type MappingServiceErrorV1 = 'VERSION_NOT_FOUND'; -export type MappingServiceResultV1 = MappingResultV1 | { readonly accepted: false; readonly code: MappingServiceErrorV1 }; +export type MappingServiceResultV1 = + | MappingResultV1 + | { readonly accepted: false; readonly code: MappingServiceErrorV1 }; export class MappingService { public constructor(private readonly repository: MappingRepositoryPortV1) {} - public async create(context: IamTenantContextV1, input: Parameters[0]): Promise> { + public async create( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { const created = createMappingDefinitionV1(input); if (!created.accepted) return created; return this.repository.withTransaction(context, async (transaction) => { @@ -29,10 +34,16 @@ export class MappingService { }); } - public async publish(context: IamTenantContextV1, versionId: StableIdentifierV1, nextVersionIdInput: unknown, publishedAt: unknown): Promise> { + public async publish( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + nextVersionIdInput: unknown, + publishedAt: unknown, + ): Promise> { return this.repository.withTransaction(context, async (transaction) => { const current = await transaction.find(context, versionId); - if (!current) return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + if (!current) + return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); const published = publishMappingDefinitionV1(current, nextVersionIdInput, publishedAt); if (!published.accepted) return published; await transaction.save(context, published.value); @@ -40,7 +51,12 @@ export class MappingService { }); } - public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { - return this.repository.withTransaction(context, (transaction) => transaction.list(context, datasetId)); + public async list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetId), + ); } } diff --git a/services/api/src/features/dsm/application/reference-entity-repository.port.ts b/services/api/src/features/dsm/application/reference-entity-repository.port.ts index a464cb2c..ada6fba7 100644 --- a/services/api/src/features/dsm/application/reference-entity-repository.port.ts +++ b/services/api/src/features/dsm/application/reference-entity-repository.port.ts @@ -1,4 +1,7 @@ -import type { BusinessPartyResolutionV1, BusinessPartyVersionV1 } from '@databreeze/domain/reference-entity/v1'; +import type { + BusinessPartyResolutionV1, + BusinessPartyVersionV1, +} from '@databreeze/domain/reference-entity/v1'; import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -7,13 +10,28 @@ export const REFERENCE_ENTITY_REPOSITORY_PORT = Symbol('REFERENCE_ENTITY_REPOSIT export interface ReferenceEntityTransactionPortV1 { saveVersion(context: IamTenantContextV1, version: BusinessPartyVersionV1): Promise; - findVersion(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise; - findLatest(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise; - listVersions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise; + findVersion( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise; + findLatest( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise; + listVersions( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise; saveResolution(context: IamTenantContextV1, resolution: BusinessPartyResolutionV1): Promise; - listResolutions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise; + listResolutions( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise; } export interface ReferenceEntityRepositoryPortV1 extends ReferenceEntityTransactionPortV1 { - withTransaction(context: IamTenantContextV1, work: (transaction: ReferenceEntityTransactionPortV1) => Promise): Promise; + withTransaction( + context: IamTenantContextV1, + work: (transaction: ReferenceEntityTransactionPortV1) => Promise, + ): Promise; } diff --git a/services/api/src/features/dsm/application/reference-entity.service.ts b/services/api/src/features/dsm/application/reference-entity.service.ts index 0079875e..249402f2 100644 --- a/services/api/src/features/dsm/application/reference-entity.service.ts +++ b/services/api/src/features/dsm/application/reference-entity.service.ts @@ -5,18 +5,26 @@ import { type BusinessPartyVersionV1, type ReferenceEntityResultV1, } from '@databreeze/domain/reference-entity/v1'; -import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { ReferenceEntityRepositoryPortV1 } from './reference-entity-repository.port.js'; export type ReferenceEntityServiceErrorV1 = 'ENTITY_NOT_FOUND' | 'ACTOR_MISMATCH'; -export type ReferenceEntityServiceResultV1 = ReferenceEntityResultV1 | { readonly accepted: false; readonly code: ReferenceEntityServiceErrorV1 }; +export type ReferenceEntityServiceResultV1 = + | ReferenceEntityResultV1 + | { readonly accepted: false; readonly code: ReferenceEntityServiceErrorV1 }; export class ReferenceEntityService { public constructor(private readonly repository: ReferenceEntityRepositoryPortV1) {} - public async create(context: IamTenantContextV1, input: Parameters[0]): Promise> { + public async create( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { const created = createBusinessPartyVersionV1(input); if (!created.accepted) return created; return this.repository.withTransaction(context, async (transaction) => { @@ -30,36 +38,57 @@ export class ReferenceEntityService { }); } - public async merge(context: IamTenantContextV1, input: { - readonly sourceEntityId: unknown; - readonly targetEntityId: unknown; - readonly resolutionId: unknown; - readonly actorId: unknown; - readonly reason: unknown; - readonly evidenceId: unknown; - readonly resolvedAt: unknown; - }): Promise> { + public async merge( + context: IamTenantContextV1, + input: { + readonly sourceEntityId: unknown; + readonly targetEntityId: unknown; + readonly resolutionId: unknown; + readonly actorId: unknown; + readonly reason: unknown; + readonly evidenceId: unknown; + readonly resolvedAt: unknown; + }, + ): Promise> { const sourceEntityId = parseStableIdentifierV1(input.sourceEntityId); const targetEntityId = parseStableIdentifierV1(input.targetEntityId); const actorId = parseStableIdentifierV1(input.actorId); - if (!sourceEntityId.accepted || !targetEntityId.accepted || !actorId.accepted) return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); - if (actorId.value !== context.actorId) return Object.freeze({ accepted: false as const, code: 'ACTOR_MISMATCH' as const }); + if (!sourceEntityId.accepted || !targetEntityId.accepted || !actorId.accepted) + return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); + if (actorId.value !== context.actorId) + return Object.freeze({ accepted: false as const, code: 'ACTOR_MISMATCH' as const }); return this.repository.withTransaction(context, async (transaction) => { const source = await transaction.findLatest(context, sourceEntityId.value); const target = await transaction.findLatest(context, targetEntityId.value); - if (!source || !target) return Object.freeze({ accepted: false as const, code: 'ENTITY_NOT_FOUND' as const }); - const resolution = mergeBusinessPartyVersionsV1({ source, target, ...input, actorId: actorId.value }); + if (!source || !target) + return Object.freeze({ accepted: false as const, code: 'ENTITY_NOT_FOUND' as const }); + const resolution = mergeBusinessPartyVersionsV1({ + source, + target, + ...input, + actorId: actorId.value, + }); if (!resolution.accepted) return resolution; await transaction.saveResolution(context, resolution.value); return resolution; }); } - public async listVersions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { - return this.repository.withTransaction(context, (transaction) => transaction.listVersions(context, entityId)); + public async listVersions( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.listVersions(context, entityId), + ); } - public async listResolutions(context: IamTenantContextV1, entityId: StableIdentifierV1): Promise { - return this.repository.withTransaction(context, (transaction) => transaction.listResolutions(context, entityId)); + public async listResolutions( + context: IamTenantContextV1, + entityId: StableIdentifierV1, + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.listResolutions(context, entityId), + ); } } diff --git a/services/api/src/features/dsm/application/rule-set-repository.port.ts b/services/api/src/features/dsm/application/rule-set-repository.port.ts index a020a25d..2178b658 100644 --- a/services/api/src/features/dsm/application/rule-set-repository.port.ts +++ b/services/api/src/features/dsm/application/rule-set-repository.port.ts @@ -7,10 +7,19 @@ export const RULE_SET_REPOSITORY_PORT = Symbol('RULE_SET_REPOSITORY_PORT'); export interface RuleSetTransactionPortV1 { save(context: IamTenantContextV1, definition: RuleSetDefinitionV1): Promise; - find(context: IamTenantContextV1, versionId: StableIdentifierV1): Promise; - list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise; + find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise; + list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise; } export interface RuleSetRepositoryPortV1 extends RuleSetTransactionPortV1 { - withTransaction(context: IamTenantContextV1, work: (transaction: RuleSetTransactionPortV1) => Promise): Promise; + withTransaction( + context: IamTenantContextV1, + work: (transaction: RuleSetTransactionPortV1) => Promise, + ): Promise; } diff --git a/services/api/src/features/dsm/application/rule-set.service.ts b/services/api/src/features/dsm/application/rule-set.service.ts index 3f543554..cbb8de26 100644 --- a/services/api/src/features/dsm/application/rule-set.service.ts +++ b/services/api/src/features/dsm/application/rule-set.service.ts @@ -10,12 +10,17 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js import type { RuleSetRepositoryPortV1 } from './rule-set-repository.port.js'; export type RuleSetServiceErrorV1 = 'VERSION_NOT_FOUND'; -export type RuleSetServiceResultV1 = RuleSetResultV1 | { readonly accepted: false; readonly code: RuleSetServiceErrorV1 }; +export type RuleSetServiceResultV1 = + | RuleSetResultV1 + | { readonly accepted: false; readonly code: RuleSetServiceErrorV1 }; export class RuleSetService { public constructor(private readonly repository: RuleSetRepositoryPortV1) {} - public async create(context: IamTenantContextV1, input: Parameters[0]): Promise> { + public async create( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { const created = createRuleSetDefinitionV1(input); if (!created.accepted) return created; return this.repository.withTransaction(context, async (transaction) => { @@ -29,10 +34,16 @@ export class RuleSetService { }); } - public async publish(context: IamTenantContextV1, versionId: StableIdentifierV1, nextVersionIdInput: unknown, publishedAt: unknown): Promise> { + public async publish( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + nextVersionIdInput: unknown, + publishedAt: unknown, + ): Promise> { return this.repository.withTransaction(context, async (transaction) => { const current = await transaction.find(context, versionId); - if (!current) return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + if (!current) + return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); const published = publishRuleSetDefinitionV1(current, nextVersionIdInput, publishedAt); if (!published.accepted) return published; await transaction.save(context, published.value); @@ -40,7 +51,12 @@ export class RuleSetService { }); } - public async list(context: IamTenantContextV1, datasetId: StableIdentifierV1): Promise { - return this.repository.withTransaction(context, (transaction) => transaction.list(context, datasetId)); + public async list( + context: IamTenantContextV1, + datasetId: StableIdentifierV1, + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetId), + ); } } diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts index 00120d55..30e48555 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts @@ -1,8 +1,4 @@ -import { - tenantScopeContainsV1, - type InboxItemV1, - type TenantScopeV1, -} from '@databreeze/domain/v1'; +import { tenantScopeContainsV1, type InboxItemV1, type TenantScopeV1 } from '@databreeze/domain/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { @@ -35,8 +31,7 @@ export class InMemoryArtifactIntakeRepositoryAdapter implements ArtifactIntakeRe if (!canMutate(context, item.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); const existing = this.items.get(item.inboxItemId); if (existing && JSON.stringify(existing) !== JSON.stringify(item)) { - if (context.expectedRevision !== existing.revision) - throw new Error('IAE_REVISION_CONFLICT'); + if (context.expectedRevision !== existing.revision) throw new Error('IAE_REVISION_CONFLICT'); if ( existing.artifactVersionId !== item.artifactVersionId || existing.idempotencyKey !== item.idempotencyKey || @@ -62,7 +57,8 @@ export class InMemoryArtifactIntakeRepositoryAdapter implements ArtifactIntakeRe await Promise.resolve(); const item = [...this.items.values()].find( (candidate) => - candidate.idempotencyKey === idempotencyKey && visible(context.tenantScope, candidate.tenantScope), + candidate.idempotencyKey === idempotencyKey && + visible(context.tenantScope, candidate.tenantScope), ); return item ? clone(item) : undefined; } diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts index 8897e441..448b447d 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts @@ -19,7 +19,9 @@ function clone(lineage: ArtifactLineageV1): ArtifactLineageV1 { ...lineage, tenantScope: Object.freeze({ ...lineage.tenantScope }), sourceArtifactVersionIds: Object.freeze([...lineage.sourceArtifactVersionIds]), - coordinateLineage: Object.freeze(lineage.coordinateLineage.map((item) => Object.freeze({ ...item }))), + coordinateLineage: Object.freeze( + lineage.coordinateLineage.map((item) => Object.freeze({ ...item })), + ), }); } diff --git a/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts index c98717f7..bbe9a77b 100644 --- a/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts @@ -1,8 +1,15 @@ -import { tenantScopeContainsV1, type EvidenceAccessGrantV1, type TenantScopeV1 } from '@databreeze/domain/v1'; +import { + tenantScopeContainsV1, + type EvidenceAccessGrantV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; -import type { EvidenceGrantRepositoryPortV1, EvidenceGrantTransactionPortV1 } from '../application/evidence-grant-repository.port.js'; +import type { + EvidenceGrantRepositoryPortV1, + EvidenceGrantTransactionPortV1, +} from '../application/evidence-grant-repository.port.js'; function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); @@ -19,13 +26,18 @@ export class InMemoryEvidenceGrantRepositoryAdapter implements EvidenceGrantRepo public async save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise { await Promise.resolve(); - if (!tenantScopeContainsV1(context.tenantScope, grant.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + if (!tenantScopeContainsV1(context.tenantScope, grant.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); const existing = this.grants.get(grant.grantId); - if (existing && JSON.stringify(existing) !== JSON.stringify(grant)) throw new Error('IAE_IMMUTABLE_GRANT'); + if (existing && JSON.stringify(existing) !== JSON.stringify(grant)) + throw new Error('IAE_IMMUTABLE_GRANT'); this.grants.set(grant.grantId, clone(grant)); } - public async find(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise { + public async find( + context: IamTenantContextV1, + grantId: StableIdentifierV1, + ): Promise { await Promise.resolve(); const grant = this.grants.get(grantId); return grant && visible(context.tenantScope, grant.tenantScope) ? clone(grant) : undefined; @@ -37,20 +49,33 @@ export class InMemoryEvidenceGrantRepositoryAdapter implements EvidenceGrantRepo this.revoked.add(grantId); } - public async isRevoked(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise { + public async isRevoked( + context: IamTenantContextV1, + grantId: StableIdentifierV1, + ): Promise { const grant = await this.find(context, grantId); return grant !== undefined && this.revoked.has(grantId); } - public async withTransaction(context: IamTenantContextV1, work: (transaction: EvidenceGrantTransactionPortV1) => Promise): Promise { + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: EvidenceGrantTransactionPortV1) => Promise, + ): Promise { let release!: () => void; const previous = this.transactionTail; - this.transactionTail = new Promise((resolve) => { release = resolve; }); + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); await previous; const beforeGrants = new Map(this.grants); const beforeRevoked = new Set(this.revoked); try { - return await work({ save: this.save.bind(this), find: this.find.bind(this), revoke: this.revoke.bind(this), isRevoked: this.isRevoked.bind(this) }); + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + revoke: this.revoke.bind(this), + isRevoked: this.isRevoked.bind(this), + }); } catch (error) { this.grants = beforeGrants; this.revoked = beforeRevoked; diff --git a/services/api/src/features/iae/api/evidence-grant.controller.ts b/services/api/src/features/iae/api/evidence-grant.controller.ts index 36da2ff6..b721efff 100644 --- a/services/api/src/features/iae/api/evidence-grant.controller.ts +++ b/services/api/src/features/iae/api/evidence-grant.controller.ts @@ -1,11 +1,20 @@ import { Body, Controller, Delete, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ARTIFACT_REPOSITORY_PORT, type ArtifactRepositoryPortV1 } from '../application/artifact-repository.port.js'; -import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1 } from '../application/evidence-grant-repository.port.js'; +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { + EVIDENCE_GRANT_REPOSITORY_PORT, + type EvidenceGrantRepositoryPortV1, +} from '../application/evidence-grant-repository.port.js'; import { EvidenceGrantService } from '../application/evidence-grant.service.js'; import { CreateEvidenceGrantDto } from './evidence-grant.dto.js'; -import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1 } from '../../../platform/http/request-tenant-context.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; @ApiTags('artifacts') @ApiBearerAuth() @@ -24,7 +33,12 @@ export class EvidenceGrantController { @Post(':versionId/evidence/:evidenceId/grants') @ApiOperation({ summary: 'Issue a short-lived exact-evidence access grant' }) @ApiBody({ type: CreateEvidenceGrantDto }) - async issue(@Req() request: unknown, @Param('versionId') versionId: string, @Param('evidenceId') evidenceId: string, @Body() input: CreateEvidenceGrantDto): Promise { + async issue( + @Req() request: unknown, + @Param('versionId') versionId: string, + @Param('evidenceId') evidenceId: string, + @Body() input: CreateEvidenceGrantDto, + ): Promise { const context = await this.requestContext.resolve(request); return this.grants.issueForEvidence(context, { ...input, versionId, evidenceId }); } diff --git a/services/api/src/features/iae/application/artifact-governance.service.ts b/services/api/src/features/iae/application/artifact-governance.service.ts index 1eedcd8b..12234130 100644 --- a/services/api/src/features/iae/application/artifact-governance.service.ts +++ b/services/api/src/features/iae/application/artifact-governance.service.ts @@ -25,7 +25,10 @@ export class ArtifactGovernanceService { const created = createArtifactLineageV1(input); if (!created.accepted) return created; return this.repository.withTransaction(context, async (transaction) => { - const existing = await transaction.findByDerived(context, created.value.derivedArtifactVersionId); + const existing = await transaction.findByDerived( + context, + created.value.derivedArtifactVersionId, + ); if (existing) { if (JSON.stringify(existing) === JSON.stringify(created.value)) return Object.freeze({ accepted: true as const, value: existing }); diff --git a/services/api/src/features/iae/application/artifact-intake-repository.port.ts b/services/api/src/features/iae/application/artifact-intake-repository.port.ts index 3d9591dc..f6be7a82 100644 --- a/services/api/src/features/iae/application/artifact-intake-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-intake-repository.port.ts @@ -10,7 +10,10 @@ export interface ArtifactIntakeTransactionPortV1 { context: IamTenantContextV1, idempotencyKey: string, ): Promise; - find(context: IamTenantContextV1, inboxItemId: InboxItemV1['inboxItemId']): Promise; + find( + context: IamTenantContextV1, + inboxItemId: InboxItemV1['inboxItemId'], + ): Promise; list(context: IamTenantContextV1): Promise; } diff --git a/services/api/src/features/iae/application/artifact-intake.service.ts b/services/api/src/features/iae/application/artifact-intake.service.ts index d4b34e89..69545c82 100644 --- a/services/api/src/features/iae/application/artifact-intake.service.ts +++ b/services/api/src/features/iae/application/artifact-intake.service.ts @@ -50,14 +50,24 @@ export class ArtifactIntakeService { inboxItemId: InboxItemV1['inboxItemId'], artifact: ArtifactVersionV1, input: Omit[0], 'artifact'>, - ): Promise> { + ): Promise< + ArtifactIntakeServiceResultV1<{ + item: InboxItemV1; + status: 'ACTIVE' | 'QUARANTINED'; + scanState: ArtifactScanStateV1; + }> + > { return this.repository.withTransaction(context, async (transaction) => { const item = await transaction.find(context, inboxItemId); if (!item) return Object.freeze({ accepted: false, code: 'INBOX_NOT_FOUND' as const }); const admission = finalizeArtifactAdmissionV1({ artifact, ...input }); if (!admission.accepted) return admission; - const next = transitionInboxItemV1(item, admission.value.status === 'ACTIVE' ? 'ROUTED' : 'QUARANTINED'); - if (!next.accepted) return Object.freeze({ accepted: false, code: 'INVALID_TRANSITION' as const }); + const next = transitionInboxItemV1( + item, + admission.value.status === 'ACTIVE' ? 'ROUTED' : 'QUARANTINED', + ); + if (!next.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_TRANSITION' as const }); await transaction.save(context, next.value); return Object.freeze({ accepted: true, diff --git a/services/api/src/features/iae/application/artifact-lineage-repository.port.ts b/services/api/src/features/iae/application/artifact-lineage-repository.port.ts index 5d925936..d2f98860 100644 --- a/services/api/src/features/iae/application/artifact-lineage-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-lineage-repository.port.ts @@ -5,10 +5,7 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js export const ARTIFACT_LINEAGE_REPOSITORY_PORT = Symbol('ARTIFACT_LINEAGE_REPOSITORY_PORT'); export interface ArtifactLineageTransactionPortV1 { - save( - context: IamTenantContextV1, - lineage: ArtifactLineageV1, - ): Promise; + save(context: IamTenantContextV1, lineage: ArtifactLineageV1): Promise; findByDerived( context: IamTenantContextV1, derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], diff --git a/services/api/src/features/iae/application/derived-artifact.service.ts b/services/api/src/features/iae/application/derived-artifact.service.ts index df1b2d97..639a8580 100644 --- a/services/api/src/features/iae/application/derived-artifact.service.ts +++ b/services/api/src/features/iae/application/derived-artifact.service.ts @@ -13,7 +13,10 @@ import { type ContentPlacementV1, type EvidenceReferenceV1, } from '@databreeze/domain/artifact/v1'; -import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { ArtifactLineageRepositoryPortV1 } from './artifact-lineage-repository.port.js'; @@ -59,22 +62,30 @@ export class DerivedArtifactService { const sourceIds: StableIdentifierV1[] = []; for (const candidate of input.sourceArtifactVersionIds) { const parsed = parseStableIdentifierV1(candidate); - if (!parsed.accepted) return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); + if (!parsed.accepted) + return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); sourceIds.push(parsed.value); } - const sourceVersions = await this.artifactRepository.withTransaction(context, async (transaction) => { - const values: ArtifactVersionV1[] = []; - for (const sourceId of sourceIds) { - const source = await transaction.findVersion(context, sourceId); - if (!source) return undefined; - values.push(source); - } - return values; - }); - if (!sourceVersions) return Object.freeze({ accepted: false as const, code: 'SOURCE_NOT_FOUND' as const }); + const sourceVersions = await this.artifactRepository.withTransaction( + context, + async (transaction) => { + const values: ArtifactVersionV1[] = []; + for (const sourceId of sourceIds) { + const source = await transaction.findVersion(context, sourceId); + if (!source) return undefined; + values.push(source); + } + return values; + }, + ); + if (!sourceVersions) + return Object.freeze({ accepted: false as const, code: 'SOURCE_NOT_FOUND' as const }); const policy = validateDerivedArtifactVersionV1({ derived: version.value, sourceVersions }); if (!policy.accepted) return policy; - const placement = createContentPlacementV1({ ...input.placement, artifactVersion: version.value }); + const placement = createContentPlacementV1({ + ...input.placement, + artifactVersion: version.value, + }); if (!placement.accepted) return placement; const evidence = input.evidence ? createEvidenceReferenceV1({ ...input.evidence, artifactVersion: version.value }) diff --git a/services/api/src/features/iae/application/evidence-grant-repository.port.ts b/services/api/src/features/iae/application/evidence-grant-repository.port.ts index 9eee675b..3608bebf 100644 --- a/services/api/src/features/iae/application/evidence-grant-repository.port.ts +++ b/services/api/src/features/iae/application/evidence-grant-repository.port.ts @@ -7,11 +7,17 @@ export const EVIDENCE_GRANT_REPOSITORY_PORT = Symbol('EVIDENCE_GRANT_REPOSITORY_ export interface EvidenceGrantTransactionPortV1 { save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise; - find(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise; + find( + context: IamTenantContextV1, + grantId: StableIdentifierV1, + ): Promise; revoke(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise; isRevoked(context: IamTenantContextV1, grantId: StableIdentifierV1): Promise; } export interface EvidenceGrantRepositoryPortV1 extends EvidenceGrantTransactionPortV1 { - withTransaction(context: IamTenantContextV1, work: (transaction: EvidenceGrantTransactionPortV1) => Promise): Promise; + withTransaction( + context: IamTenantContextV1, + work: (transaction: EvidenceGrantTransactionPortV1) => Promise, + ): Promise; } diff --git a/services/api/src/features/iae/application/evidence-grant.service.ts b/services/api/src/features/iae/application/evidence-grant.service.ts index 98aa1bfd..3e177d69 100644 --- a/services/api/src/features/iae/application/evidence-grant.service.ts +++ b/services/api/src/features/iae/application/evidence-grant.service.ts @@ -1,12 +1,25 @@ -import { createEvidenceAccessGrantV1, type EvidenceAccessGrantV1, type EvidenceGrantResultV1 } from '@databreeze/domain/evidence-grant/v1'; +import { + createEvidenceAccessGrantV1, + type EvidenceAccessGrantV1, + type EvidenceGrantResultV1, +} from '@databreeze/domain/evidence-grant/v1'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; import type { EvidenceGrantRepositoryPortV1 } from './evidence-grant-repository.port.js'; -export type EvidenceGrantServiceErrorV1 = 'GRANT_NOT_FOUND' | 'GRANT_REVOKED' | 'GRANT_EXPIRED' | 'DEVICE_MISMATCH' | 'EPOCH_MISMATCH' | 'EVIDENCE_NOT_FOUND' | 'ARTIFACT_REPOSITORY_UNAVAILABLE'; -export type EvidenceGrantServiceResultV1 = EvidenceGrantResultV1 | { readonly accepted: false; readonly code: EvidenceGrantServiceErrorV1 }; +export type EvidenceGrantServiceErrorV1 = + | 'GRANT_NOT_FOUND' + | 'GRANT_REVOKED' + | 'GRANT_EXPIRED' + | 'DEVICE_MISMATCH' + | 'EPOCH_MISMATCH' + | 'EVIDENCE_NOT_FOUND' + | 'ARTIFACT_REPOSITORY_UNAVAILABLE'; +export type EvidenceGrantServiceResultV1 = + | EvidenceGrantResultV1 + | { readonly accepted: false; readonly code: EvidenceGrantServiceErrorV1 }; export class EvidenceGrantService { public constructor( @@ -14,8 +27,12 @@ export class EvidenceGrantService { private readonly artifactRepository?: ArtifactRepositoryPortV1, ) {} - public async issue(context: IamTenantContextV1, input: Omit[0], 'tenantScope'>): Promise> { - if (input.authorizationEpoch !== context.authorizationEpoch) return { accepted: false, code: 'EPOCH_MISMATCH' }; + public async issue( + context: IamTenantContextV1, + input: Omit[0], 'tenantScope'>, + ): Promise> { + if (input.authorizationEpoch !== context.authorizationEpoch) + return { accepted: false, code: 'EPOCH_MISMATCH' }; const created = createEvidenceAccessGrantV1({ ...input, tenantScope: context.tenantScope }); if (!created.accepted) return created; return this.repository.withTransaction(context, async (transaction) => { @@ -29,46 +46,75 @@ export class EvidenceGrantService { }); } - public async resolve(context: IamTenantContextV1, input: { readonly grantId: unknown; readonly recipientDeviceId: unknown; readonly authorizationEpoch: unknown; readonly now: unknown }): Promise> { + public async resolve( + context: IamTenantContextV1, + input: { + readonly grantId: unknown; + readonly recipientDeviceId: unknown; + readonly authorizationEpoch: unknown; + readonly now: unknown; + }, + ): Promise> { const grantId = parseStableIdentifierV1(input.grantId); const recipientDeviceId = parseStableIdentifierV1(input.recipientDeviceId); - if (!grantId.accepted || !recipientDeviceId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; - if (typeof input.authorizationEpoch !== 'number' || !Number.isSafeInteger(input.authorizationEpoch) || input.authorizationEpoch < 1) return { accepted: false, code: 'INVALID_EPOCH' }; - if (input.authorizationEpoch !== context.authorizationEpoch) return { accepted: false, code: 'EPOCH_MISMATCH' }; - if (typeof input.now !== 'string' || Number.isNaN(Date.parse(input.now))) return { accepted: false, code: 'INVALID_TIMESTAMP' }; + if (!grantId.accepted || !recipientDeviceId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' }; + if ( + typeof input.authorizationEpoch !== 'number' || + !Number.isSafeInteger(input.authorizationEpoch) || + input.authorizationEpoch < 1 + ) + return { accepted: false, code: 'INVALID_EPOCH' }; + if (input.authorizationEpoch !== context.authorizationEpoch) + return { accepted: false, code: 'EPOCH_MISMATCH' }; + if (typeof input.now !== 'string' || Number.isNaN(Date.parse(input.now))) + return { accepted: false, code: 'INVALID_TIMESTAMP' }; const now = input.now; return this.repository.withTransaction(context, async (transaction) => { const grant = await transaction.find(context, grantId.value); if (!grant) return { accepted: false as const, code: 'GRANT_NOT_FOUND' as const }; - if (await transaction.isRevoked(context, grant.grantId)) return { accepted: false as const, code: 'GRANT_REVOKED' as const }; - if (grant.recipientDeviceId !== recipientDeviceId.value) return { accepted: false as const, code: 'DEVICE_MISMATCH' as const }; - if (grant.authorizationEpoch !== input.authorizationEpoch) return { accepted: false as const, code: 'EPOCH_MISMATCH' as const }; - if (Date.parse(now) >= Date.parse(grant.expiresAt)) return { accepted: false as const, code: 'GRANT_EXPIRED' as const }; + if (await transaction.isRevoked(context, grant.grantId)) + return { accepted: false as const, code: 'GRANT_REVOKED' as const }; + if (grant.recipientDeviceId !== recipientDeviceId.value) + return { accepted: false as const, code: 'DEVICE_MISMATCH' as const }; + if (grant.authorizationEpoch !== input.authorizationEpoch) + return { accepted: false as const, code: 'EPOCH_MISMATCH' as const }; + if (Date.parse(now) >= Date.parse(grant.expiresAt)) + return { accepted: false as const, code: 'GRANT_EXPIRED' as const }; return { accepted: true as const, value: grant }; }); } /** Derives data mode and source state from the exact immutable artifact record. */ - public async issueForEvidence(context: IamTenantContextV1, input: { - readonly versionId: unknown; - readonly evidenceId: unknown; - readonly grantId: unknown; - readonly recipientDeviceId: unknown; - readonly action: unknown; - readonly issuedAt: unknown; - readonly expiresAt: unknown; - readonly authorizationEpoch: unknown; - readonly maxExcerptBytes?: unknown; - }): Promise> { - if (!this.artifactRepository) return { accepted: false, code: 'ARTIFACT_REPOSITORY_UNAVAILABLE' }; + public async issueForEvidence( + context: IamTenantContextV1, + input: { + readonly versionId: unknown; + readonly evidenceId: unknown; + readonly grantId: unknown; + readonly recipientDeviceId: unknown; + readonly action: unknown; + readonly issuedAt: unknown; + readonly expiresAt: unknown; + readonly authorizationEpoch: unknown; + readonly maxExcerptBytes?: unknown; + }, + ): Promise> { + if (!this.artifactRepository) + return { accepted: false, code: 'ARTIFACT_REPOSITORY_UNAVAILABLE' }; const versionId = parseStableIdentifierV1(input.versionId); const evidenceId = parseStableIdentifierV1(input.evidenceId); - if (!versionId.accepted || !evidenceId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; + if (!versionId.accepted || !evidenceId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' }; const source = await this.artifactRepository.withTransaction(context, async (transaction) => { const version = await transaction.findVersion(context, versionId.value); if (!version) return undefined; - const evidence = (await transaction.listEvidence(context, versionId.value)).find((candidate) => candidate.evidenceId === evidenceId.value); - return evidence ? { dataMode: version.dataMode, sourceState: evidence.sourceState } : undefined; + const evidence = (await transaction.listEvidence(context, versionId.value)).find( + (candidate) => candidate.evidenceId === evidenceId.value, + ); + return evidence + ? { dataMode: version.dataMode, sourceState: evidence.sourceState } + : undefined; }); if (!source) return { accepted: false, code: 'EVIDENCE_NOT_FOUND' }; return this.issue(context, { @@ -80,7 +126,10 @@ export class EvidenceGrantService { }); } - public async revoke(context: IamTenantContextV1, grantIdInput: unknown): Promise> { + public async revoke( + context: IamTenantContextV1, + grantIdInput: unknown, + ): Promise> { const grantId = parseStableIdentifierV1(grantIdInput); if (!grantId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; return this.repository.withTransaction(context, async (transaction) => { diff --git a/services/api/test/features/dsm/governed-dataset.service.test.ts b/services/api/test/features/dsm/governed-dataset.service.test.ts index 6a1a5d85..63be8aa7 100644 --- a/services/api/test/features/dsm/governed-dataset.service.test.ts +++ b/services/api/test/features/dsm/governed-dataset.service.test.ts @@ -38,7 +38,14 @@ const input = { versionId: '00000000-0000-4000-8000-000000000021', tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, name: 'Orders', - fields: [{ fieldId: '00000000-0000-4000-8000-000000000022', name: 'amount', type: 'DECIMAL', nullable: true }], + fields: [ + { + fieldId: '00000000-0000-4000-8000-000000000022', + name: 'amount', + type: 'DECIMAL', + nullable: true, + }, + ], createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'a'.repeat(64), }; @@ -62,11 +69,18 @@ void test('[DSM-001, DSM-004, DSM-005, DSM-006] service creates, publishes, comp stable('00000000-0000-4000-8000-000000000023'), ); assert.deepEqual(comparison, { accepted: true, value: 'ADDITIVE_COMPATIBLE' }); - assert.equal((await service.list(context(workspaceId, 'governed-4'), stable(input.datasetId))).length, 2); + assert.equal( + (await service.list(context(workspaceId, 'governed-4'), stable(input.datasetId))).length, + 2, + ); }); void test('[IAM-009, DSM-018] governed definitions do not cross sibling workspaces', async () => { const service = new GovernedDatasetService(new InMemoryGovernedDatasetRepositoryAdapter()); await service.create(context(workspaceId, 'governed-scope-1'), input); - assert.equal((await service.list(context(siblingWorkspaceId, 'governed-scope-2'), stable(input.datasetId))).length, 0); + assert.equal( + (await service.list(context(siblingWorkspaceId, 'governed-scope-2'), stable(input.datasetId))) + .length, + 0, + ); }); diff --git a/services/api/test/features/dsm/mapping.service.test.ts b/services/api/test/features/dsm/mapping.service.test.ts index d901f97c..75aa27fd 100644 --- a/services/api/test/features/dsm/mapping.service.test.ts +++ b/services/api/test/features/dsm/mapping.service.test.ts @@ -14,7 +14,13 @@ const actorId = '00000000-0000-4000-8000-000000000010'; const correlationId = '00000000-0000-4000-8000-000000000011'; function context(workspaceIdValue: string, idempotencyKey: string) { - const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspaceIdValue }, actorId, correlationId, idempotencyKey, authorizationEpoch: 1 }); + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspaceIdValue }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); assert.equal(result.accepted, true); if (!result.accepted) throw new Error('invalid context'); return result.value; @@ -28,9 +34,20 @@ function stable(value: string) { } const input = { - datasetId: '00000000-0000-4000-8000-000000000020', versionId: '00000000-0000-4000-8000-000000000021', - tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, sourceSchemaVersionId: '00000000-0000-4000-8000-000000000022', targetSchemaVersionId: '00000000-0000-4000-8000-000000000023', - steps: [{ sourceFieldId: '00000000-0000-4000-8000-000000000024', targetFieldId: '00000000-0000-4000-8000-000000000025', transform: 'TRIM' }], createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'a'.repeat(64), + datasetId: '00000000-0000-4000-8000-000000000020', + versionId: '00000000-0000-4000-8000-000000000021', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceSchemaVersionId: '00000000-0000-4000-8000-000000000022', + targetSchemaVersionId: '00000000-0000-4000-8000-000000000023', + steps: [ + { + sourceFieldId: '00000000-0000-4000-8000-000000000024', + targetFieldId: '00000000-0000-4000-8000-000000000025', + transform: 'TRIM', + }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), }; void test('[DSM-007, DSM-008] mapping service versions and publishes definitions', async () => { @@ -38,13 +55,25 @@ void test('[DSM-007, DSM-008] mapping service versions and publishes definitions const created = await service.create(context(workspaceId, 'mapping-create'), input); assert.equal(created.accepted, true); if (!created.accepted) return; - const published = await service.publish(context(workspaceId, 'mapping-publish'), stable(input.versionId), '00000000-0000-4000-8000-000000000026', '2026-01-01T00:01:00.000Z'); + const published = await service.publish( + context(workspaceId, 'mapping-publish'), + stable(input.versionId), + '00000000-0000-4000-8000-000000000026', + '2026-01-01T00:01:00.000Z', + ); assert.equal(published.accepted, true); - assert.equal((await service.list(context(workspaceId, 'mapping-list'), stable(input.datasetId))).length, 2); + assert.equal( + (await service.list(context(workspaceId, 'mapping-list'), stable(input.datasetId))).length, + 2, + ); }); void test('[IAM-009, DSM-007] sibling workspaces cannot read mappings', async () => { const service = new MappingService(new InMemoryMappingRepositoryAdapter()); await service.create(context(workspaceId, 'mapping-scope-create'), input); - assert.equal((await service.list(context(siblingWorkspaceId, 'mapping-scope-list'), stable(input.datasetId))).length, 0); + assert.equal( + (await service.list(context(siblingWorkspaceId, 'mapping-scope-list'), stable(input.datasetId))) + .length, + 0, + ); }); diff --git a/services/api/test/features/dsm/reference-entity.service.test.ts b/services/api/test/features/dsm/reference-entity.service.test.ts index 2531ef18..c691e746 100644 --- a/services/api/test/features/dsm/reference-entity.service.test.ts +++ b/services/api/test/features/dsm/reference-entity.service.test.ts @@ -14,7 +14,13 @@ const correlationId = '00000000-0000-4000-8000-000000000011'; const scope = { scopeType: 'workspace' as const, organizationId, workspaceId }; function context(idempotencyKey: string) { - const result = createIamTenantContextV1({ tenantScope: scope, actorId, correlationId, idempotencyKey, authorizationEpoch: 1 }); + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); assert.equal(result.accepted, true); if (!result.accepted) throw new Error('invalid context'); return result.value; @@ -29,28 +35,86 @@ function stable(value: string) { function party(entityId: string, versionId: string, displayName: string) { return { - entityId, versionId, tenantScope: scope, displayName, roles: ['SUPPLIER'], aliases: [], externalIdentifiers: [], canonicalHash: 'a'.repeat(64), createdAt: '2026-01-01T00:00:00.000Z', + entityId, + versionId, + tenantScope: scope, + displayName, + roles: ['SUPPLIER'], + aliases: [], + externalIdentifiers: [], + canonicalHash: 'a'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', } as const; } void test('[DSM-025, DSM-026] reference entities remain immutable and merges are actor-bound', async () => { const service = new ReferenceEntityService(new InMemoryReferenceEntityRepositoryAdapter()); - const source = await service.create(context('party-source'), party('00000000-0000-4000-8000-000000000020', '00000000-0000-4000-8000-000000000021', 'Source Supplier')); - const target = await service.create(context('party-target'), party('00000000-0000-4000-8000-000000000022', '00000000-0000-4000-8000-000000000023', 'Target Supplier')); + const source = await service.create( + context('party-source'), + party( + '00000000-0000-4000-8000-000000000020', + '00000000-0000-4000-8000-000000000021', + 'Source Supplier', + ), + ); + const target = await service.create( + context('party-target'), + party( + '00000000-0000-4000-8000-000000000022', + '00000000-0000-4000-8000-000000000023', + 'Target Supplier', + ), + ); assert.equal(source.accepted, true); assert.equal(target.accepted, true); const resolution = await service.merge(context('party-merge'), { - sourceEntityId: source.accepted ? source.value.entityId : '', targetEntityId: target.accepted ? target.value.entityId : '', resolutionId: '00000000-0000-4000-8000-000000000024', actorId, reason: 'Verified duplicate', evidenceId: '00000000-0000-4000-8000-000000000025', resolvedAt: '2026-01-01T00:01:00.000Z', + sourceEntityId: source.accepted ? source.value.entityId : '', + targetEntityId: target.accepted ? target.value.entityId : '', + resolutionId: '00000000-0000-4000-8000-000000000024', + actorId, + reason: 'Verified duplicate', + evidenceId: '00000000-0000-4000-8000-000000000025', + resolvedAt: '2026-01-01T00:01:00.000Z', }); assert.equal(resolution.accepted, true); - assert.equal((await service.listResolutions(context('party-read'), stable('00000000-0000-4000-8000-000000000020'))).length, 1); - assert.equal((await service.listVersions(context('party-history'), stable('00000000-0000-4000-8000-000000000020'))).length, 1); + assert.equal( + ( + await service.listResolutions( + context('party-read'), + stable('00000000-0000-4000-8000-000000000020'), + ) + ).length, + 1, + ); + assert.equal( + ( + await service.listVersions( + context('party-history'), + stable('00000000-0000-4000-8000-000000000020'), + ) + ).length, + 1, + ); }); void test('[DSM-027] a merge cannot be authored by a different actor', async () => { const service = new ReferenceEntityService(new InMemoryReferenceEntityRepositoryAdapter()); - await service.create(context('party-a'), party('00000000-0000-4000-8000-000000000030', '00000000-0000-4000-8000-000000000031', 'A')); - await service.create(context('party-b'), party('00000000-0000-4000-8000-000000000032', '00000000-0000-4000-8000-000000000033', 'B')); - const result = await service.merge(context('party-actor-mismatch'), { sourceEntityId: '00000000-0000-4000-8000-000000000030', targetEntityId: '00000000-0000-4000-8000-000000000032', resolutionId: '00000000-0000-4000-8000-000000000034', actorId: '00000000-0000-4000-8000-000000000099', reason: 'No', evidenceId: '00000000-0000-4000-8000-000000000035', resolvedAt: '2026-01-01T00:01:00.000Z' }); + await service.create( + context('party-a'), + party('00000000-0000-4000-8000-000000000030', '00000000-0000-4000-8000-000000000031', 'A'), + ); + await service.create( + context('party-b'), + party('00000000-0000-4000-8000-000000000032', '00000000-0000-4000-8000-000000000033', 'B'), + ); + const result = await service.merge(context('party-actor-mismatch'), { + sourceEntityId: '00000000-0000-4000-8000-000000000030', + targetEntityId: '00000000-0000-4000-8000-000000000032', + resolutionId: '00000000-0000-4000-8000-000000000034', + actorId: '00000000-0000-4000-8000-000000000099', + reason: 'No', + evidenceId: '00000000-0000-4000-8000-000000000035', + resolvedAt: '2026-01-01T00:01:00.000Z', + }); assert.deepEqual(result, { accepted: false, code: 'ACTOR_MISMATCH' }); }); diff --git a/services/api/test/features/dsm/rule-set.service.test.ts b/services/api/test/features/dsm/rule-set.service.test.ts index bef9ed90..b0dff08c 100644 --- a/services/api/test/features/dsm/rule-set.service.test.ts +++ b/services/api/test/features/dsm/rule-set.service.test.ts @@ -13,7 +13,13 @@ const actorId = '00000000-0000-4000-8000-000000000010'; const correlationId = '00000000-0000-4000-8000-000000000011'; function context(idempotencyKey: string) { - const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, actorId, correlationId, idempotencyKey, authorizationEpoch: 1 }); + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); assert.equal(result.accepted, true); if (!result.accepted) throw new Error('invalid context'); return result.value; @@ -27,8 +33,20 @@ function stable(value: string) { } const input = { - datasetId: '00000000-0000-4000-8000-000000000020', versionId: '00000000-0000-4000-8000-000000000021', tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, schemaVersionId: '00000000-0000-4000-8000-000000000022', createdAt: '2026-01-01T00:00:00.000Z', canonicalHash: 'a'.repeat(64), - rules: [{ ruleId: '00000000-0000-4000-8000-000000000023', fieldId: '00000000-0000-4000-8000-000000000024', kind: 'REQUIRED', severity: 'ERROR' }], + datasetId: '00000000-0000-4000-8000-000000000020', + versionId: '00000000-0000-4000-8000-000000000021', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + schemaVersionId: '00000000-0000-4000-8000-000000000022', + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + rules: [ + { + ruleId: '00000000-0000-4000-8000-000000000023', + fieldId: '00000000-0000-4000-8000-000000000024', + kind: 'REQUIRED', + severity: 'ERROR', + }, + ], }; void test('[DSM-009, DSM-010, DSM-011] rule-set service versions and publishes deterministic rules', async () => { @@ -36,12 +54,27 @@ void test('[DSM-009, DSM-010, DSM-011] rule-set service versions and publishes d const created = await service.create(context('rules-create'), input); assert.equal(created.accepted, true); if (!created.accepted) return; - assert.equal((await service.publish(context('rules-publish'), stable(input.versionId), '00000000-0000-4000-8000-000000000025', '2026-01-01T00:01:00.000Z')).accepted, true); + assert.equal( + ( + await service.publish( + context('rules-publish'), + stable(input.versionId), + '00000000-0000-4000-8000-000000000025', + '2026-01-01T00:01:00.000Z', + ) + ).accepted, + true, + ); assert.equal((await service.list(context('rules-list'), stable(input.datasetId))).length, 2); }); void test('[DSM-009] missing rule-set versions return a stable application error', async () => { const service = new RuleSetService(new InMemoryRuleSetRepositoryAdapter()); - const result = await service.publish(context('rules-missing'), stable('00000000-0000-4000-8000-000000000026'), '00000000-0000-4000-8000-000000000027', '2026-01-01T00:01:00.000Z'); + const result = await service.publish( + context('rules-missing'), + stable('00000000-0000-4000-8000-000000000026'), + '00000000-0000-4000-8000-000000000027', + '2026-01-01T00:01:00.000Z', + ); assert.deepEqual(result, { accepted: false, code: 'VERSION_NOT_FOUND' }); }); diff --git a/services/api/test/features/iae/artifact-governance.service.test.ts b/services/api/test/features/iae/artifact-governance.service.test.ts index 26d47d2b..374933c4 100644 --- a/services/api/test/features/iae/artifact-governance.service.test.ts +++ b/services/api/test/features/iae/artifact-governance.service.test.ts @@ -58,11 +58,19 @@ void test('[IAE-007, IAE-012] lineage is immutable, idempotent, and tenant scope const repeated = await service.registerLineage(context(workspaceId, 'lineage-2'), input); assert.deepEqual(repeated, created); assert.equal( - (await service.findForDerived(context(siblingWorkspaceId, 'lineage-read'), stable(input.derivedArtifactVersionId))), + await service.findForDerived( + context(siblingWorkspaceId, 'lineage-read'), + stable(input.derivedArtifactVersionId), + ), undefined, ); assert.equal( - (await service.listForSource(context(workspaceId, 'lineage-source'), stable(sourceArtifactVersionId))).length, + ( + await service.listForSource( + context(workspaceId, 'lineage-source'), + stable(sourceArtifactVersionId), + ) + ).length, 1, ); }); @@ -71,7 +79,9 @@ void test('[IAE-007] lineage rejects cross-scope sources and conflicting derived const service = new ArtifactGovernanceService(new InMemoryArtifactLineageRepositoryAdapter()); const crossScope = await service.registerLineage(context(workspaceId, 'lineage-cross'), { ...input, - sourceTenantScopes: [{ scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }], + sourceTenantScopes: [ + { scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }, + ], }); assert.deepEqual(crossScope, { accepted: false, code: 'CROSS_SCOPE' }); await service.registerLineage(context(workspaceId, 'lineage-conflict-a'), input); diff --git a/services/api/test/features/iae/artifact-intake.service.test.ts b/services/api/test/features/iae/artifact-intake.service.test.ts index ac71af70..6a7b8f5e 100644 --- a/services/api/test/features/iae/artifact-intake.service.test.ts +++ b/services/api/test/features/iae/artifact-intake.service.test.ts @@ -55,7 +55,15 @@ void test('[IAE-001] create returns the same inbox item for a repeated key', asy const first = await service.create(context(workspaceId, 'create-1'), inbox); const second = await service.create(context(workspaceId, 'create-2'), inbox); assert.deepEqual(second, first); - assert.equal((await service.create(context(workspaceId, 'create-3'), { ...inbox, artifactVersionId: '00000000-0000-4000-8000-000000000023' })).accepted, false); + assert.equal( + ( + await service.create(context(workspaceId, 'create-3'), { + ...inbox, + artifactVersionId: '00000000-0000-4000-8000-000000000023', + }) + ).accepted, + false, + ); }); void test('[IAE-009, IAE-010, IAM-009] admission moves clean content to routed and quarantines malicious content', async () => { @@ -63,31 +71,57 @@ void test('[IAE-009, IAE-010, IAM-009] admission moves clean content to routed a const created = await service.create(context(workspaceId, 'admit-1'), inbox); assert.equal(created.accepted, true); if (!created.accepted) return; - const admitted = await service.admit(context(workspaceId, 'admit-2', created.value.revision), created.value.inboxItemId, artifact, { - actualSha256: artifact.contentSha256, - actualByteSize: artifact.byteSize, - detectedMediaType: artifact.mediaType, - scanState: 'CLEAN', - maxByteSize: 100, - }); + const admitted = await service.admit( + context(workspaceId, 'admit-2', created.value.revision), + created.value.inboxItemId, + artifact, + { + actualSha256: artifact.contentSha256, + actualByteSize: artifact.byteSize, + detectedMediaType: artifact.mediaType, + scanState: 'CLEAN', + maxByteSize: 100, + }, + ); assert.equal(admitted.accepted, true); if (!admitted.accepted) return; assert.equal(admitted.value.item.state, 'ROUTED'); - const sibling = await service.admit(context(siblingWorkspaceId, 'admit-3'), created.value.inboxItemId, artifact, { - actualSha256: artifact.contentSha256, - actualByteSize: artifact.byteSize, - detectedMediaType: artifact.mediaType, - scanState: 'CLEAN', - maxByteSize: 100, - }); + const sibling = await service.admit( + context(siblingWorkspaceId, 'admit-3'), + created.value.inboxItemId, + artifact, + { + actualSha256: artifact.contentSha256, + actualByteSize: artifact.byteSize, + detectedMediaType: artifact.mediaType, + scanState: 'CLEAN', + maxByteSize: 100, + }, + ); assert.deepEqual(sibling, { accepted: false, code: 'INBOX_NOT_FOUND' }); }); void test('[IAE-001, IAM-009] inbox listing is scoped and newest-first', async () => { const service = new ArtifactIntakeService(new InMemoryArtifactIntakeRepositoryAdapter()); await service.create(context(workspaceId, 'list-1'), inbox); - await service.create(context(workspaceId, 'list-2'), { ...inbox, inboxItemId: '00000000-0000-4000-8000-000000000024', artifactVersionId: '00000000-0000-4000-8000-000000000025', createdAt: '2026-01-02T00:00:00.000Z', idempotencyKey: 'list-2' }); - await service.create(context(siblingWorkspaceId, 'list-3'), { ...inbox, inboxItemId: '00000000-0000-4000-8000-000000000026', artifactVersionId: '00000000-0000-4000-8000-000000000027', tenantScope: { scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }, createdAt: '2026-01-03T00:00:00.000Z', idempotencyKey: 'list-3' }); + await service.create(context(workspaceId, 'list-2'), { + ...inbox, + inboxItemId: '00000000-0000-4000-8000-000000000024', + artifactVersionId: '00000000-0000-4000-8000-000000000025', + createdAt: '2026-01-02T00:00:00.000Z', + idempotencyKey: 'list-2', + }); + await service.create(context(siblingWorkspaceId, 'list-3'), { + ...inbox, + inboxItemId: '00000000-0000-4000-8000-000000000026', + artifactVersionId: '00000000-0000-4000-8000-000000000027', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }, + createdAt: '2026-01-03T00:00:00.000Z', + idempotencyKey: 'list-3', + }); const listed = await service.list(context(workspaceId, 'list-read')); - assert.deepEqual(listed.map((item) => item.inboxItemId), ['00000000-0000-4000-8000-000000000024', inbox.inboxItemId]); + assert.deepEqual( + listed.map((item) => item.inboxItemId), + ['00000000-0000-4000-8000-000000000024', inbox.inboxItemId], + ); }); diff --git a/services/api/test/features/iae/derived-artifact.service.test.ts b/services/api/test/features/iae/derived-artifact.service.test.ts index 41361f8d..a8077303 100644 --- a/services/api/test/features/iae/derived-artifact.service.test.ts +++ b/services/api/test/features/iae/derived-artifact.service.test.ts @@ -54,7 +54,9 @@ void test('[IAE-007, IAE-008] derivative registration resolves sources and persi const lineage = new InMemoryArtifactLineageRepositoryAdapter(); const service = new DerivedArtifactService(artifacts, lineage); const source = sourceInput(); - const sourceService = new (await import('../../../src/features/iae/application/artifact.service.js')).ArtifactService(artifacts); + const sourceService = new ( + await import('../../../src/features/iae/application/artifact.service.js') + ).ArtifactService(artifacts); const registered = await sourceService.register(context('source'), source); assert.equal(registered.accepted, true); if (!registered.accepted) return; @@ -87,13 +89,21 @@ void test('[IAE-007, IAE-008] derivative registration resolves sources and persi }); assert.equal(derived.accepted, true); if (!derived.accepted) return; - assert.equal((await lineage.findByDerived(context('read'), derived.value.version.versionId))?.lineageId, '00000000-0000-4000-8000-000000000033'); + assert.equal( + (await lineage.findByDerived(context('read'), derived.value.version.versionId))?.lineageId, + '00000000-0000-4000-8000-000000000033', + ); }); void test('[IAE-008] Local source cannot produce a Hybrid derivative', async () => { const artifacts = new InMemoryArtifactRepositoryAdapter(); - const service = new DerivedArtifactService(artifacts, new InMemoryArtifactLineageRepositoryAdapter()); - const sourceService = new (await import('../../../src/features/iae/application/artifact.service.js')).ArtifactService(artifacts); + const service = new DerivedArtifactService( + artifacts, + new InMemoryArtifactLineageRepositoryAdapter(), + ); + const sourceService = new ( + await import('../../../src/features/iae/application/artifact.service.js') + ).ArtifactService(artifacts); const source = sourceInput('Local'); const registered = await sourceService.register(context('local-source'), source); assert.equal(registered.accepted, true); @@ -119,24 +129,54 @@ void test('[IAE-008] Local source cannot produce a Hybrid derivative', async () contentSha256: 'c'.repeat(64), }, sourceArtifactVersionIds: [source.version.versionId], - lineage: { lineageId: '00000000-0000-4000-8000-000000000043', processorVersion: 'test@1', coordinateLineage: [] }, + lineage: { + lineageId: '00000000-0000-4000-8000-000000000043', + processorVersion: 'test@1', + coordinateLineage: [], + }, }); assert.deepEqual(rejected, { accepted: false, code: 'DATA_MODE_WIDENING' }); }); void test('[IAE-007] missing source prevents any derivative write', async () => { const artifacts = new InMemoryArtifactRepositoryAdapter(); - const service = new DerivedArtifactService(artifacts, new InMemoryArtifactLineageRepositoryAdapter()); + const service = new DerivedArtifactService( + artifacts, + new InMemoryArtifactLineageRepositoryAdapter(), + ); const rejected = await service.register(context('missing-source'), { version: { - artifactId: '00000000-0000-4000-8000-000000000050', versionId: '00000000-0000-4000-8000-000000000051', - tenantScope: scope, sourceKind: 'GENERATED', dataMode: 'Local', contentSha256: 'd'.repeat(64), byteSize: 1, - mediaType: 'text/csv', displayName: 'missing.csv', createdAt: '2026-01-01T00:00:01.000Z', + artifactId: '00000000-0000-4000-8000-000000000050', + versionId: '00000000-0000-4000-8000-000000000051', + tenantScope: scope, + sourceKind: 'GENERATED', + dataMode: 'Local', + contentSha256: 'd'.repeat(64), + byteSize: 1, + mediaType: 'text/csv', + displayName: 'missing.csv', + createdAt: '2026-01-01T00:00:01.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000052', + tenantScope: scope, + kind: 'LOCAL', + opaqueReference: 'missing-reference_1234', + contentSha256: 'd'.repeat(64), }, - placement: { placementId: '00000000-0000-4000-8000-000000000052', tenantScope: scope, kind: 'LOCAL', opaqueReference: 'missing-reference_1234', contentSha256: 'd'.repeat(64) }, sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000053'], - lineage: { lineageId: '00000000-0000-4000-8000-000000000054', processorVersion: 'test@1', coordinateLineage: [] }, + lineage: { + lineageId: '00000000-0000-4000-8000-000000000054', + processorVersion: 'test@1', + coordinateLineage: [], + }, }); assert.deepEqual(rejected, { accepted: false, code: 'SOURCE_NOT_FOUND' }); - assert.equal((await artifacts.findVersion(context('missing-read'), '00000000-0000-4000-8000-000000000051' as never)), undefined); + assert.equal( + await artifacts.findVersion( + context('missing-read'), + '00000000-0000-4000-8000-000000000051' as never, + ), + undefined, + ); }); diff --git a/services/api/test/features/iae/evidence-grant.service.test.ts b/services/api/test/features/iae/evidence-grant.service.test.ts index 81f6cf69..79e4f1c1 100644 --- a/services/api/test/features/iae/evidence-grant.service.test.ts +++ b/services/api/test/features/iae/evidence-grant.service.test.ts @@ -12,35 +12,90 @@ const correlationId = '00000000-0000-4000-8000-000000000011'; const deviceId = '00000000-0000-4000-8000-000000000012'; function context(idempotencyKey: string) { - const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, actorId, correlationId, idempotencyKey, authorizationEpoch: 2 }); + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 2, + }); assert.equal(result.accepted, true); if (!result.accepted) throw new Error('invalid context'); return result.value; } const input = { - grantId: '00000000-0000-4000-8000-000000000020', evidenceId: '00000000-0000-4000-8000-000000000021', artifactVersionId: '00000000-0000-4000-8000-000000000022', recipientDeviceId: deviceId, action: 'EXCERPT', issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T00:05:00.000Z', authorizationEpoch: 2, artifactDataMode: 'Hybrid', sourceState: 'AVAILABLE', + grantId: '00000000-0000-4000-8000-000000000020', + evidenceId: '00000000-0000-4000-8000-000000000021', + artifactVersionId: '00000000-0000-4000-8000-000000000022', + recipientDeviceId: deviceId, + action: 'EXCERPT', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T00:05:00.000Z', + authorizationEpoch: 2, + artifactDataMode: 'Hybrid', + sourceState: 'AVAILABLE', } as const; void test('[IAE-005] service issues and resolves an epoch-bound grant', async () => { const service = new EvidenceGrantService(new InMemoryEvidenceGrantRepositoryAdapter()); const issued = await service.issue(context('grant-issue'), input); assert.equal(issued.accepted, true); - const resolved = await service.resolve(context('grant-resolve'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 2, now: '2026-01-01T00:01:00.000Z' }); + const resolved = await service.resolve(context('grant-resolve'), { + grantId: input.grantId, + recipientDeviceId: deviceId, + authorizationEpoch: 2, + now: '2026-01-01T00:01:00.000Z', + }); assert.equal(resolved.accepted, true); }); void test('[IAE-005, IAM-020] revoked, expired, and mismatched grants fail closed', async () => { const service = new EvidenceGrantService(new InMemoryEvidenceGrantRepositoryAdapter()); await service.issue(context('grant-fail'), input); - assert.deepEqual(await service.resolve(context('grant-device'), { grantId: input.grantId, recipientDeviceId: '00000000-0000-4000-8000-000000000099', authorizationEpoch: 2, now: '2026-01-01T00:01:00.000Z' }), { accepted: false, code: 'DEVICE_MISMATCH' }); - assert.deepEqual(await service.resolve(context('grant-epoch'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 3, now: '2026-01-01T00:01:00.000Z' }), { accepted: false, code: 'EPOCH_MISMATCH' }); - assert.deepEqual(await service.resolve(context('grant-expired'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 2, now: '2026-01-01T00:06:00.000Z' }), { accepted: false, code: 'GRANT_EXPIRED' }); + assert.deepEqual( + await service.resolve(context('grant-device'), { + grantId: input.grantId, + recipientDeviceId: '00000000-0000-4000-8000-000000000099', + authorizationEpoch: 2, + now: '2026-01-01T00:01:00.000Z', + }), + { accepted: false, code: 'DEVICE_MISMATCH' }, + ); + assert.deepEqual( + await service.resolve(context('grant-epoch'), { + grantId: input.grantId, + recipientDeviceId: deviceId, + authorizationEpoch: 3, + now: '2026-01-01T00:01:00.000Z', + }), + { accepted: false, code: 'EPOCH_MISMATCH' }, + ); + assert.deepEqual( + await service.resolve(context('grant-expired'), { + grantId: input.grantId, + recipientDeviceId: deviceId, + authorizationEpoch: 2, + now: '2026-01-01T00:06:00.000Z', + }), + { accepted: false, code: 'GRANT_EXPIRED' }, + ); await service.revoke(context('grant-revoke'), input.grantId); - assert.deepEqual(await service.resolve(context('grant-revoked'), { grantId: input.grantId, recipientDeviceId: deviceId, authorizationEpoch: 2, now: '2026-01-01T00:01:00.000Z' }), { accepted: false, code: 'GRANT_REVOKED' }); + assert.deepEqual( + await service.resolve(context('grant-revoked'), { + grantId: input.grantId, + recipientDeviceId: deviceId, + authorizationEpoch: 2, + now: '2026-01-01T00:01:00.000Z', + }), + { accepted: false, code: 'GRANT_REVOKED' }, + ); }); void test('[IAM-020] issuing a grant with a stale authorization epoch is rejected', async () => { const service = new EvidenceGrantService(new InMemoryEvidenceGrantRepositoryAdapter()); - assert.deepEqual(await service.issue(context('grant-stale-epoch'), { ...input, authorizationEpoch: 1 }), { accepted: false, code: 'EPOCH_MISMATCH' }); + assert.deepEqual( + await service.issue(context('grant-stale-epoch'), { ...input, authorizationEpoch: 1 }), + { accepted: false, code: 'EPOCH_MISMATCH' }, + ); }); From abcd3fc15655cced40328a112ffcfba3035283ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 11:56:06 +0700 Subject: [PATCH 41/44] fix(lint): clear repository check violations --- apps/web/src/features/inbox/inbox-api.ts | 2 +- packages/domain/src/rule-set/v1.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/inbox/inbox-api.ts b/apps/web/src/features/inbox/inbox-api.ts index 14bada48..27540a10 100644 --- a/apps/web/src/features/inbox/inbox-api.ts +++ b/apps/web/src/features/inbox/inbox-api.ts @@ -24,7 +24,7 @@ export interface InboxListItem { } function apiBaseUrl(): string { - const configured = import.meta.env['VITE_DATABREEZE_API_BASE_URL']; + const configured: unknown = import.meta.env['VITE_DATABREEZE_API_BASE_URL']; if (typeof configured !== 'string' || configured.trim() === '') return ''; return configured.replace(/\/$/u, ''); } diff --git a/packages/domain/src/rule-set/v1.ts b/packages/domain/src/rule-set/v1.ts index 051754b1..06a43d0c 100644 --- a/packages/domain/src/rule-set/v1.ts +++ b/packages/domain/src/rule-set/v1.ts @@ -123,7 +123,7 @@ function rule(input: unknown): QualityRuleV1 | RuleSetErrorCodeV1 { } else if (kind === 'REFERENCE') { if (!identifier((parameters as Record)['referenceEntityVersionId'])) return 'INVALID_PARAMETERS'; - } else if (Object.keys(parameters as object).length > 0) { + } else if (Object.keys(parameters).length > 0) { return 'INVALID_PARAMETERS'; } return Object.freeze({ From 3e03de17a61024b6ae2ed72d92a33bf77958f437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 12:03:33 +0700 Subject: [PATCH 42/44] fix(api): refresh generated openapi artifact --- services/api/openapi/v1.json | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index e1ed87d0..ad1a717b 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1624,7 +1624,14 @@ "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, - "required": ["datasetId", "versionId", "name", "fields", "createdAt", "canonicalHash"] + "required": [ + "datasetId", + "versionId", + "name", + "fields", + "createdAt", + "canonicalHash" + ] }, "MappingStepDto": { "type": "object", @@ -1678,7 +1685,13 @@ "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, - "required": ["versionId", "schemaVersionId", "rules", "createdAt", "canonicalHash"] + "required": [ + "versionId", + "schemaVersionId", + "rules", + "createdAt", + "canonicalHash" + ] }, "CreateReferenceEntityDto": { "type": "object", @@ -1701,7 +1714,14 @@ "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "createdAt": { "type": "string", "format": "date-time" } }, - "required": ["entityId", "versionId", "displayName", "roles", "canonicalHash", "createdAt"] + "required": [ + "entityId", + "versionId", + "displayName", + "roles", + "canonicalHash", + "createdAt" + ] }, "MergeReferenceEntityDto": { "type": "object", From 7046106e4389756b7f92299420c1e332c5e187c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 12:07:01 +0700 Subject: [PATCH 43/44] fix(api): align openapi generation with repository formatting --- services/api/openapi/v1.json | 224 ++++------------------ services/api/scripts/generate-openapi.mjs | 12 +- services/api/test/openapi-drift.test.ts | 12 +- 3 files changed, 59 insertions(+), 189 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index ad1a717b..fe374708 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -19,9 +19,7 @@ "content": { "application/json": { "schema": { - "properties": { - "status": { "enum": ["ok"], "type": "string" } - }, + "properties": { "status": { "enum": ["ok"], "type": "string" } }, "type": "object" } } @@ -96,9 +94,7 @@ "content": { "application/json": { "schema": { - "properties": { - "status": { "enum": ["ready"], "type": "string" } - }, + "properties": { "status": { "enum": ["ready"], "type": "string" } }, "type": "object" } } @@ -153,9 +149,7 @@ "503": { "description": "", "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }, "headers": { "X-Correlation-Id": { @@ -181,11 +175,7 @@ "name": "clientPlatform", "required": true, "in": "query", - "schema": { - "example": "web", - "type": "string", - "enum": ["android", "desktop", "web"] - } + "schema": { "example": "web", "type": "string", "enum": ["android", "desktop", "web"] } }, { "name": "clientVersion", @@ -290,9 +280,7 @@ "required": true, "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ClientCompatibilityDto" - } + "schema": { "$ref": "#/components/schemas/ClientCompatibilityDto" } } } }, @@ -379,18 +367,14 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/SignInDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/SignInDto" } } } }, "responses": { "200": { "description": "", "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/AuthSessionDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/AuthSessionDto" } } }, "headers": { "X-Correlation-Id": { @@ -485,9 +469,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } } } }, "responses": { @@ -616,18 +598,8 @@ "post": { "operationId": "EvidenceGrantController.issue", "parameters": [ - { - "name": "versionId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, - { - "name": "evidenceId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -640,9 +612,7 @@ "required": true, "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEvidenceGrantDto" - } + "schema": { "$ref": "#/components/schemas/CreateEvidenceGrantDto" } } } }, @@ -706,12 +676,7 @@ "delete": { "operationId": "EvidenceGrantController.revoke", "parameters": [ - { - "name": "grantId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -792,9 +757,7 @@ "required": true, "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateGovernedDatasetDto" - } + "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } } } }, @@ -858,12 +821,7 @@ "get": { "operationId": "GovernedDatasetController.list", "parameters": [ - { - "name": "datasetId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -932,12 +890,7 @@ "post": { "operationId": "MappingController.create", "parameters": [ - { - "name": "datasetId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -949,9 +902,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateMappingDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } } }, "responses": { @@ -1012,12 +963,7 @@ "get": { "operationId": "MappingController.list", "parameters": [ - { - "name": "datasetId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1086,12 +1032,7 @@ "post": { "operationId": "RuleSetController.create", "parameters": [ - { - "name": "datasetId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1103,9 +1044,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } } }, "responses": { @@ -1166,12 +1105,7 @@ "get": { "operationId": "RuleSetController.list", "parameters": [ - { - "name": "datasetId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1252,9 +1186,7 @@ "required": true, "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateReferenceEntityDto" - } + "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } } } }, @@ -1330,9 +1262,7 @@ "required": true, "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/MergeReferenceEntityDto" - } + "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } } } }, @@ -1396,12 +1326,7 @@ "get": { "operationId": "ReferenceEntityController.list", "parameters": [ - { - "name": "entityId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1498,21 +1423,9 @@ "SignInDto": { "type": "object", "properties": { - "email": { - "type": "string", - "example": "ngu***@example.com", - "maxLength": 254 - }, - "password": { - "type": "string", - "minLength": 12, - "maxLength": 128, - "writeOnly": true - }, - "clientPlatform": { - "type": "string", - "enum": ["android", "desktop", "web"] - } + "email": { "type": "string", "example": "ngu***@example.com", "maxLength": 254 }, + "password": { "type": "string", "minLength": 12, "maxLength": 128, "writeOnly": true }, + "clientPlatform": { "type": "string", "enum": ["android", "desktop", "web"] } }, "required": ["email", "password", "clientPlatform"] }, @@ -1523,16 +1436,8 @@ "userId": { "type": "string", "format": "uuid" }, "organizationId": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, - "accessToken": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - }, - "refreshToken": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - }, + "accessToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "refreshToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, "accessExpiresAt": { "type": "string", "format": "date-time" }, "securityEpoch": { "type": "number", "minimum": 1 }, "mfaRequired": { "type": "boolean" } @@ -1555,11 +1460,7 @@ "inboxItemId": { "type": "string", "format": "uuid" }, "artifactVersionId": { "type": "string", "format": "uuid" }, "createdAt": { "type": "string", "format": "date-time" }, - "idempotencyKey": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } + "idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 200 } }, "required": ["inboxItemId", "artifactVersionId", "createdAt"] }, @@ -1568,10 +1469,7 @@ "properties": { "grantId": { "type": "string", "format": "uuid" }, "recipientDeviceId": { "type": "string", "format": "uuid" }, - "action": { - "type": "string", - "enum": ["COORDINATE", "EXCERPT", "OPEN_ON_DEVICE"] - }, + "action": { "type": "string", "enum": ["COORDINATE", "EXCERPT", "OPEN_ON_DEVICE"] }, "issuedAt": { "type": "string", "format": "date-time" }, "expiresAt": { "type": "string", "format": "date-time" }, "authorizationEpoch": { "type": "number", "minimum": 1 }, @@ -1591,10 +1489,7 @@ "properties": { "fieldId": { "type": "string", "format": "uuid" }, "name": { "type": "string", "maxLength": 128 }, - "type": { - "type": "string", - "enum": ["TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE"] - }, + "type": { "type": "string", "enum": ["TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE"] }, "nullable": { "type": "boolean" }, "unit": { "type": "string", "maxLength": 64 }, "semanticRole": { "type": "string", "maxLength": 128 }, @@ -1604,10 +1499,7 @@ "type": "string", "enum": ["PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED"] }, - "defaultBehavior": { - "type": "string", - "enum": ["MISSING", "NULL", "STATIC", "NONE"] - } + "defaultBehavior": { "type": "string", "enum": ["MISSING", "NULL", "STATIC", "NONE"] } }, "required": ["fieldId", "name", "type", "nullable"] }, @@ -1624,14 +1516,7 @@ "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, - "required": [ - "datasetId", - "versionId", - "name", - "fields", - "createdAt", - "canonicalHash" - ] + "required": ["datasetId", "versionId", "name", "fields", "createdAt", "canonicalHash"] }, "MappingStepDto": { "type": "object", @@ -1660,10 +1545,7 @@ "versionId": { "type": "string", "format": "uuid" }, "sourceSchemaVersionId": { "type": "string", "format": "uuid" }, "targetSchemaVersionId": { "type": "string", "format": "uuid" }, - "steps": { - "type": "array", - "items": { "$ref": "#/components/schemas/MappingStepDto" } - }, + "steps": { "type": "array", "items": { "$ref": "#/components/schemas/MappingStepDto" } }, "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, @@ -1685,13 +1567,7 @@ "createdAt": { "type": "string", "format": "date-time" }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, - "required": [ - "versionId", - "schemaVersionId", - "rules", - "createdAt", - "canonicalHash" - ] + "required": ["versionId", "schemaVersionId", "rules", "createdAt", "canonicalHash"] }, "CreateReferenceEntityDto": { "type": "object", @@ -1701,27 +1577,14 @@ "displayName": { "type": "string", "maxLength": 255 }, "roles": { "type": "array", - "items": { - "type": "string", - "enum": ["SUPPLIER", "CUSTOMER", "CARRIER", "OTHER"] - } + "items": { "type": "string", "enum": ["SUPPLIER", "CUSTOMER", "CARRIER", "OTHER"] } }, "aliases": { "type": "array", "items": { "type": "string" } }, - "externalIdentifiers": { - "type": "array", - "items": { "type": "object" } - }, + "externalIdentifiers": { "type": "array", "items": { "type": "object" } }, "canonicalHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "createdAt": { "type": "string", "format": "date-time" } }, - "required": [ - "entityId", - "versionId", - "displayName", - "roles", - "canonicalHash", - "createdAt" - ] + "required": ["entityId", "versionId", "displayName", "roles", "canonicalHash", "createdAt"] }, "MergeReferenceEntityDto": { "type": "object", @@ -1791,20 +1654,13 @@ "required": ["field", "code"], "properties": { "field": { "type": "string", "minLength": 1, "maxLength": 255 }, - "code": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]{0,127}$" - } + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,127}$" } } } }, "retryAfterSeconds": { "type": "integer", "minimum": 0 }, "currentRevision": { "$ref": "#/components/schemas/Revision" }, - "remediationAction": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, + "remediationAction": { "type": "string", "minLength": 1, "maxLength": 255 }, "rateLimit": { "type": "object", "additionalProperties": false, diff --git a/services/api/scripts/generate-openapi.mjs b/services/api/scripts/generate-openapi.mjs index b008cadb..81f85283 100644 --- a/services/api/scripts/generate-openapi.mjs +++ b/services/api/scripts/generate-openapi.mjs @@ -1,17 +1,23 @@ import { readFile, mkdir, writeFile } from 'node:fs/promises'; import process from 'node:process'; -import { URL } from 'node:url'; +import { URL, fileURLToPath } from 'node:url'; -import { format } from 'prettier'; +import { format, resolveConfig } from 'prettier'; import { createApiApplication } from '../dist/bootstrap.js'; const artifactUrl = new URL('../openapi/v1.json', import.meta.url); +const artifactPath = fileURLToPath(artifactUrl); const check = process.argv.includes('--check'); const { app, openApi } = await createApiApplication(); try { - const generated = await format(JSON.stringify(openApi), { parser: 'json' }); + const prettierConfig = (await resolveConfig(artifactPath)) ?? {}; + const generated = await format(JSON.stringify(openApi), { + ...prettierConfig, + filepath: artifactPath, + parser: 'json', + }); if (check) { const current = await readFile(artifactUrl, 'utf8').catch(() => undefined); if (current !== generated) { diff --git a/services/api/test/openapi-drift.test.ts b/services/api/test/openapi-drift.test.ts index fec50de0..12ab566d 100644 --- a/services/api/test/openapi-drift.test.ts +++ b/services/api/test/openapi-drift.test.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { format } from 'prettier'; +import { format, resolveConfig } from 'prettier'; import { createApiApplication } from '../src/bootstrap.js'; @@ -14,7 +14,15 @@ void test('the checked-in v1 OpenAPI artifact matches a fresh application genera const { app, openApi } = await createApiApplication(); try { - assert.equal(actual, await format(JSON.stringify(openApi), { parser: 'json' })); + const prettierConfig = (await resolveConfig(artifactPath)) ?? {}; + assert.equal( + actual, + await format(JSON.stringify(openApi), { + ...prettierConfig, + filepath: artifactPath, + parser: 'json', + }), + ); } finally { await app.close(); } From 3021c7d1cb8c4a0d0a162a09c1b6c45fd90905d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 12:26:03 +0700 Subject: [PATCH 44/44] fix(engine): satisfy ci formatting checks --- .../src/databreeze_engine/processors/dataset_profile.py | 4 +++- services/engine/tests/test_dataset_profile.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/dataset_profile.py b/services/engine/src/databreeze_engine/processors/dataset_profile.py index 2db18eef..81a251ef 100644 --- a/services/engine/src/databreeze_engine/processors/dataset_profile.py +++ b/services/engine/src/databreeze_engine/processors/dataset_profile.py @@ -9,7 +9,9 @@ from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr -ValueState = Literal["MISSING", "NULL", "BLANK", "INVALID", "ZERO", "NOT_APPLICABLE", "REDACTED", "VALUE"] +ValueState = Literal[ + "MISSING", "NULL", "BLANK", "INVALID", "ZERO", "NOT_APPLICABLE", "REDACTED", "VALUE" +] StateCounts = dict[ValueState, StrictInt] diff --git a/services/engine/tests/test_dataset_profile.py b/services/engine/tests/test_dataset_profile.py index dcede708..24e50f39 100644 --- a/services/engine/tests/test_dataset_profile.py +++ b/services/engine/tests/test_dataset_profile.py @@ -38,7 +38,12 @@ def test_profile_distinguishes_missing_null_blank_zero_and_not_applicable() -> N def test_profile_is_deterministic_and_discloses_sampling() -> None: rows = [{"code": "B"}, {"code": "A"}, {"code": "B"}] first = profile_records(rows, ["code"], max_rows=2, sample_seed=7) - second = profile_records([{"code": "C"}, {"code": "A"}, {"code": "B"}], ["code"], max_rows=2, sample_seed=7) + second = profile_records( + [{"code": "C"}, {"code": "A"}, {"code": "B"}], + ["code"], + max_rows=2, + sample_seed=7, + ) assert first.rowCountScanned == 2 assert first.sourceRowCount == 3 assert first.sampled is True