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..27540a10 --- /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: unknown = 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)); + }); +}); diff --git a/packages/domain/package.json b/packages/domain/package.json index 5d8d4701..1a11008f 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -48,10 +48,22 @@ "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" + }, + "./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" }, + "./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 +91,22 @@ "./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" + }, + "./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" + }, + "./evidence-grant/v1": { + "types": "./src/evidence-grant/v1.ts", + "import": "./dist/evidence-grant/v1.js" } }, "scripts": { diff --git a/packages/domain/src/artifact-governance/v1.ts b/packages/domain/src/artifact-governance/v1.ts new file mode 100644 index 00000000..a23d8957 --- /dev/null +++ b/packages/domain/src/artifact-governance/v1.ts @@ -0,0 +1,226 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + 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; + +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' + | 'SOURCE_REQUIRED' + | 'SOURCE_NOT_ACTIVE' + | 'DATA_MODE_WIDENING'; + +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), + }), + ); +} + +/** 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; + 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/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/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/src/dataset-governance/v1.ts b/packages/domain/src/dataset-governance/v1.ts new file mode 100644 index 00000000..89901f60 --- /dev/null +++ b/packages/domain/src/dataset-governance/v1.ts @@ -0,0 +1,337 @@ +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 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; + 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/evidence-grant/v1.ts b/packages/domain/src/evidence-grant/v1.ts new file mode 100644 index 00000000..0ec86fcd --- /dev/null +++ b/packages/domain/src/evidence-grant/v1.ts @@ -0,0 +1,144 @@ +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/mapping/v1.ts b/packages/domain/src/mapping/v1.ts new file mode 100644 index 00000000..60990f02 --- /dev/null +++ b/packages/domain/src/mapping/v1.ts @@ -0,0 +1,197 @@ +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/reference-entity/v1.ts b/packages/domain/src/reference-entity/v1.ts new file mode 100644 index 00000000..f4914f9d --- /dev/null +++ b/packages/domain/src/reference-entity/v1.ts @@ -0,0 +1,209 @@ +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/rule-set/v1.ts b/packages/domain/src/rule-set/v1.ts new file mode 100644 index 00000000..06a43d0c --- /dev/null +++ b/packages/domain/src/rule-set/v1.ts @@ -0,0 +1,215 @@ +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).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 4695c59b..baf6bf7d 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -1,7 +1,10 @@ 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'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; @@ -9,6 +12,10 @@ 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 './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/artifact-governance-v1.test.mjs b/packages/domain/test/artifact-governance-v1.test.mjs new file mode 100644 index 00000000..bbe404c8 --- /dev/null +++ b/packages/domain/test/artifact-governance-v1.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createArtifactLineageV1, + evaluateArtifactRetentionV1, + validateDerivedArtifactVersionV1, +} from '../dist/artifact-governance/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', +}; + +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-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', + 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/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/artifact-v1.test.mjs b/packages/domain/test/artifact-v1.test.mjs index 78f5e22a..b182283a 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,24 @@ 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' }, + ); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 68958793..7bcfdb56 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -6,7 +6,10 @@ const [ tenantScope, authorization, artifact, + artifactIntake, + artifactGovernance, dataset, + datasetGovernance, dataMode, jobs, approval, @@ -15,13 +18,20 @@ const [ dispatch, recipe, finding, + referenceEntity, + mapping, + ruleSet, + evidenceGrant, ] = 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/artifact-governance/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 +40,10 @@ const [ import('@databreeze/domain/dispatch/v1'), 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'), + import('@databreeze/domain/evidence-grant/v1'), ]); assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); @@ -38,7 +52,10 @@ 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(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); @@ -47,4 +64,8 @@ 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); 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..981c5928 --- /dev/null +++ b/packages/domain/test/dataset-governance-v1.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + compareGovernedSchemaCompatibilityV1, + createDatasetVersionManifestV1, + createGovernedDatasetDefinitionV1, + publishGovernedDatasetDefinitionV1, +} 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); +}); + +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/packages/domain/test/evidence-grant-v1.test.mjs b/packages/domain/test/evidence-grant-v1.test.mjs new file mode 100644 index 00000000..75886615 --- /dev/null +++ b/packages/domain/test/evidence-grant-v1.test.mjs @@ -0,0 +1,61 @@ +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/mapping-rule-set-v1.test.mjs b/packages/domain/test/mapping-rule-set-v1.test.mjs new file mode 100644 index 00000000..8ce1f0ca --- /dev/null +++ b/packages/domain/test/mapping-rule-set-v1.test.mjs @@ -0,0 +1,131 @@ +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 9111ee89..2d35fe9c 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -20,7 +20,10 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './device-authorization/v1', './data-mode/v1', './artifact/v1', + './artifact-intake/v1', + './artifact-governance/v1', './dataset/v1', + './dataset-governance/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -28,6 +31,10 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './dispatch/v1', './recipe/v1', './finding/v1', + './reference-entity/v1', + './mapping/v1', + './rule-set/v1', + './evidence-grant/v1', ]); for (const entry of Object.values(manifest.exports)) { @@ -52,6 +59,9 @@ 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); + assert.equal(aggregate.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); }); test('[IAM-004] does not expose an unversioned package root', async () => { 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..ac33089b --- /dev/null +++ b/packages/domain/test/reference-entity-v1.test.mjs @@ -0,0 +1,66 @@ +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); + } +}); diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index f2acc6cd..fe374708 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -19,9 +19,195 @@ "content": { "application/json": { "schema": { + "properties": { "status": { "enum": ["ok"], "type": "string" } }, + "type": "object" + } + } + }, + "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" } + } + } + } + }, + "tags": ["operational"], + "x-databreeze-audience": "operational" + } + }, + "/health/ready": { + "get": { + "operationId": "HealthController.readinessStatus", + "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": "", + "content": { + "application/json": { + "schema": { + "properties": { "status": { "enum": ["ready"], "type": "string" } }, + "type": "object" + } + } + }, + "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" } + } + } + }, + "503": { + "description": "", + "content": { + "application/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" } + } + } + } + }, + "tags": ["operational"], + "x-databreeze-audience": "operational" + } + }, + "/v1/system/compatibility": { + "get": { + "operationId": "SystemController.getCompatibility", + "parameters": [ + { + "name": "clientPlatform", + "required": true, + "in": "query", + "schema": { "example": "web", "type": "string", "enum": ["android", "desktop", "web"] } + }, + { + "name": "clientVersion", + "required": true, + "in": "query", + "schema": { + "maxLength": 32, + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + "example": "1.2.3", + "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": "", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, "properties": { - "status": { "enum": ["ok"], "type": "string" } + "apiMajorVersion": { "enum": [1], "type": "integer" }, + "status": { "enum": ["supported"], "type": "string" } }, + "required": ["apiMajorVersion", "status"], "type": "object" } } @@ -74,14 +260,710 @@ } } }, - "tags": ["operational"], - "x-databreeze-audience": "operational" - } - }, - "/health/ready": { + "summary": "Check compatibility for a released DataBreeze client", + "tags": ["system"] + } + }, + "/v1/system/compatibility/check": { + "post": { + "operationId": "SystemController.checkCompatibility", + "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/ClientCompatibilityDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "apiMajorVersion": { "enum": [1], "type": "integer" }, + "status": { "enum": ["supported"], "type": "string" } + }, + "required": ["apiMajorVersion", "status"], + "type": "object" + } + } + }, + "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" } + } + } + } + }, + "summary": "Check compatibility using a structured request body", + "tags": ["system"] + } + }, + "/v1/auth/sign-in": { + "post": { + "operationId": "AuthenticationController.signIn", + "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/SignInDto" } } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/AuthSessionDto" } } + }, + "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" } + } + } + }, + "401": { + "description": "Credentials were rejected.", + "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" } + } + } + }, + "503": { + "description": "Authentication provider is unavailable.", + "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" } + } + } + } + }, + "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"] + }, + "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": { + "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", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an immutable governed dataset definition draft", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions": { + "get": { + "operationId": "GovernedDatasetController.list", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List governed dataset versions visible to the caller", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/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": "HealthController.readinessStatus", + "operationId": "MappingController.list", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -93,16 +975,6 @@ "responses": { "200": { "description": "", - "content": { - "application/json": { - "schema": { - "properties": { - "status": { "enum": ["ready"], "type": "string" } - }, - "type": "object" - } - } - }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -149,11 +1021,68 @@ "schema": { "format": "uuid", "type": "string" } } } - }, - "503": { + } + }, + "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/json": { + "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" } } }, @@ -169,35 +1098,14 @@ } } }, - "tags": ["operational"], - "x-databreeze-audience": "operational" - } - }, - "/v1/system/compatibility": { + "security": [{ "bearer": [] }], + "summary": "Create an immutable quality rule-set draft", + "tags": ["datasets"] + }, "get": { - "operationId": "SystemController.getCompatibility", + "operationId": "RuleSetController.list", "parameters": [ - { - "name": "clientPlatform", - "required": true, - "in": "query", - "schema": { - "example": "web", - "type": "string", - "enum": ["android", "desktop", "web"] - } - }, - { - "name": "clientVersion", - "required": true, - "in": "query", - "schema": { - "maxLength": 32, - "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", - "example": "1.2.3", - "type": "string" - } - }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -209,19 +1117,6 @@ "responses": { "200": { "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "apiMajorVersion": { "enum": [1], "type": "integer" }, - "status": { "enum": ["supported"], "type": "string" } - }, - "required": ["apiMajorVersion", "status"], - "type": "object" - } - } - }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -270,13 +1165,14 @@ } } }, - "summary": "Check compatibility for a released DataBreeze client", - "tags": ["system"] + "security": [{ "bearer": [] }], + "summary": "List immutable quality rule-set versions", + "tags": ["datasets"] } }, - "/v1/system/compatibility/check": { + "/v1/reference-entities": { "post": { - "operationId": "SystemController.checkCompatibility", + "operationId": "ReferenceEntityController.create", "parameters": [ { "name": "X-Correlation-Id", @@ -290,28 +1186,13 @@ "required": true, "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ClientCompatibilityDto" - } + "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } } } }, "responses": { - "200": { + "201": { "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "apiMajorVersion": { "enum": [1], "type": "integer" }, - "status": { "enum": ["supported"], "type": "string" } - }, - "required": ["apiMajorVersion", "status"], - "type": "object" - } - } - }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -360,13 +1241,14 @@ } } }, - "summary": "Check compatibility using a structured request body", - "tags": ["system"] + "security": [{ "bearer": [] }], + "summary": "Create an immutable business-party version", + "tags": ["reference-entities"] } }, - "/v1/auth/sign-in": { + "/v1/reference-entities/merge": { "post": { - "operationId": "AuthenticationController.signIn", + "operationId": "ReferenceEntityController.merge", "parameters": [ { "name": "X-Correlation-Id", @@ -380,18 +1262,13 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SignInDto" } + "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } } } }, "responses": { - "200": { + "201": { "description": "", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/AuthSessionDto" } - } - }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -421,8 +1298,46 @@ } } }, - "401": { - "description": "Credentials were rejected.", + "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.", @@ -434,8 +1349,8 @@ } } }, - "500": { - "description": "An unexpected failure was safely mapped.", + "400": { + "description": "The request was malformed or failed closed validation.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -452,8 +1367,13 @@ } } }, - "503": { - "description": "Authentication provider is unavailable.", + "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.", @@ -466,8 +1386,9 @@ } } }, - "summary": "Sign in and issue a short-lived session", - "tags": ["auth"] + "security": [{ "bearer": [] }], + "summary": "List immutable business-party versions", + "tags": ["reference-entities"] } } }, @@ -502,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"] }, @@ -527,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" } @@ -553,6 +1454,157 @@ "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"] + }, + "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": { + "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"] + }, + "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.", @@ -602,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/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/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/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/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/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index 4677c7b0..b635d0d9 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,118 @@ 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 + 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 + 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") + @@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 e7e89a4c..0cced72a 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,46 @@ 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 + 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) + coordinateLineage Json @map("coordinate_lineage") + 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") +} + model ContentPlacement { id String @id @db.Uuid artifactVersionId String @map("artifact_version_id") @db.Uuid @@ -62,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/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/src/app.module.ts b/services/api/src/app.module.ts index 835cab36..cc0137fb 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -2,15 +2,25 @@ 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..59ae3983 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,10 @@ 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/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..474bb1db --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.ts @@ -0,0 +1,101 @@ +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/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..409237d5 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-mapping-repository.adapter.ts @@ -0,0 +1,90 @@ +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/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..2d37d19e --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.ts @@ -0,0 +1,159 @@ +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/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..043a0240 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-rule-set-repository.adapter.ts @@ -0,0 +1,94 @@ +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/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/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/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/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/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..976d8e03 --- /dev/null +++ b/services/api/src/features/dsm/api/governed-dataset.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + 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..f5152f27 --- /dev/null +++ b/services/api/src/features/dsm/api/governed-dataset.dto.ts @@ -0,0 +1,98 @@ +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/api/mapping.controller.ts b/services/api/src/features/dsm/api/mapping.controller.ts new file mode 100644 index 00000000..bc142d38 --- /dev/null +++ b/services/api/src/features/dsm/api/mapping.controller.ts @@ -0,0 +1,58 @@ +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..4b087f02 --- /dev/null +++ b/services/api/src/features/dsm/api/mapping.dto.ts @@ -0,0 +1,97 @@ +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..1c6fcbfc --- /dev/null +++ b/services/api/src/features/dsm/api/reference-entity.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + 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..c43721cf --- /dev/null +++ b/services/api/src/features/dsm/api/reference-entity.dto.ts @@ -0,0 +1,81 @@ +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..dcf3187e --- /dev/null +++ b/services/api/src/features/dsm/api/rule-set.controller.ts @@ -0,0 +1,58 @@ +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/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..7ec1903b --- /dev/null +++ b/services/api/src/features/dsm/application/governed-dataset.service.ts @@ -0,0 +1,81 @@ +import { + compareGovernedSchemaCompatibilityV1, + createGovernedDatasetDefinitionV1, + publishGovernedDatasetDefinitionV1, + type DatasetGovernanceResultV1, + type GovernedDatasetDefinitionV1, + type SchemaCompatibilityV1, +} from '@databreeze/domain/dataset-governance/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'; + +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/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..96704fa4 --- /dev/null +++ b/services/api/src/features/dsm/application/mapping-repository.port.ts @@ -0,0 +1,25 @@ +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..282ea122 --- /dev/null +++ b/services/api/src/features/dsm/application/mapping.service.ts @@ -0,0 +1,62 @@ +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/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..ada6fba7 --- /dev/null +++ b/services/api/src/features/dsm/application/reference-entity-repository.port.ts @@ -0,0 +1,37 @@ +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..249402f2 --- /dev/null +++ b/services/api/src/features/dsm/application/reference-entity.service.ts @@ -0,0 +1,94 @@ +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/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..2178b658 --- /dev/null +++ b/services/api/src/features/dsm/application/rule-set-repository.port.ts @@ -0,0 +1,25 @@ +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..cbb8de26 --- /dev/null +++ b/services/api/src/features/dsm/application/rule-set.service.ts @@ -0,0 +1,62 @@ +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/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts new file mode 100644 index 00000000..3bdb53f6 --- /dev/null +++ b/services/api/src/features/dsm/dsm.module.ts @@ -0,0 +1,116 @@ +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 { + PrismaGovernedDatasetRepositoryAdapter, + 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 { + PrismaReferenceEntityRepositoryAdapter, + type ReferenceEntityDatabaseClientV1, +} from './adapter/prisma-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, +} 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, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +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; + /** 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; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly referenceEntityDatabase?: ReferenceEntityDatabaseClientV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class DsmModule { + public static register(options: DsmModuleOptions = {}): DynamicModule { + return { + module: DsmModule, + controllers: [ + GovernedDatasetController, + MappingController, + RuleSetController, + ReferenceEntityController, + ], + providers: [ + { + provide: GOVERNED_DATASET_REPOSITORY_PORT, + useValue: + options.governedDatasetRepository ?? + (options.governedDatasetDatabase === undefined + ? new InMemoryGovernedDatasetRepositoryAdapter() + : new PrismaGovernedDatasetRepositoryAdapter(options.governedDatasetDatabase)), + }, + { + provide: MAPPING_REPOSITORY_PORT, + useValue: + options.mappingRepository ?? + (options.mappingDatabase === undefined + ? new InMemoryMappingRepositoryAdapter() + : new PrismaMappingRepositoryAdapter(options.mappingDatabase)), + }, + { + provide: RULE_SET_REPOSITORY_PORT, + useValue: + options.ruleSetRepository ?? + (options.ruleSetDatabase === undefined + ? new InMemoryRuleSetRepositoryAdapter() + : new PrismaRuleSetRepositoryAdapter(options.ruleSetDatabase)), + }, + { + provide: REFERENCE_ENTITY_REPOSITORY_PORT, + useValue: + options.referenceEntityRepository ?? + (options.referenceEntityDatabase === undefined + ? new InMemoryReferenceEntityRepositoryAdapter() + : new PrismaReferenceEntityRepositoryAdapter(options.referenceEntityDatabase)), + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + }; + } +} 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..30e48555 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts @@ -0,0 +1,108 @@ +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 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, + ): 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), + list: this.list.bind(this), + }); + } catch (error) { + this.items = before; + throw error; + } finally { + release(); + } + } +} 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..448b447d --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.ts @@ -0,0 +1,95 @@ +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/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..bbe9a77b --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-evidence-grant-repository.adapter.ts @@ -0,0 +1,87 @@ +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/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/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/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..b721efff --- /dev/null +++ b/services/api/src/features/iae/api/evidence-grant.controller.ts @@ -0,0 +1,52 @@ +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/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..0eb7f45f --- /dev/null +++ b/services/api/src/features/iae/api/inbox.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Get, 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, + }); + } + + @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-governance.service.ts b/services/api/src/features/iae/application/artifact-governance.service.ts new file mode 100644 index 00000000..12234130 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-governance.service.ts @@ -0,0 +1,65 @@ +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-intake-repository.port.ts b/services/api/src/features/iae/application/artifact-intake-repository.port.ts new file mode 100644 index 00000000..f6be7a82 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-intake-repository.port.ts @@ -0,0 +1,25 @@ +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; + list(context: IamTenantContextV1): 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..69545c82 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-intake.service.ts @@ -0,0 +1,82 @@ +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< + 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 }); + await transaction.save(context, next.value); + return Object.freeze({ + accepted: true, + value: Object.freeze({ item: next.value, ...admission.value }), + }); + }); + } + + public async list(context: IamTenantContextV1): Promise { + return this.repository.withTransaction(context, (transaction) => transaction.list(context)); + } +} 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..d2f98860 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-lineage-repository.port.ts @@ -0,0 +1,24 @@ +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/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..639a8580 --- /dev/null +++ b/services/api/src/features/iae/application/derived-artifact.service.ts @@ -0,0 +1,120 @@ +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/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..3608bebf --- /dev/null +++ b/services/api/src/features/iae/application/evidence-grant-repository.port.ts @@ -0,0 +1,23 @@ +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..3e177d69 --- /dev/null +++ b/services/api/src/features/iae/application/evidence-grant.service.ts @@ -0,0 +1,142 @@ +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 class EvidenceGrantService { + 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) => { + 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 (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 }; + 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' }; + 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' }; + 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/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts new file mode 100644 index 00000000..bff8b5a2 --- /dev/null +++ b/services/api/src/features/iae/iae.module.ts @@ -0,0 +1,84 @@ +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 { + 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, + 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, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +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; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactDatabase?: ArtifactDatabaseClientV1; + readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class IaeModule { + public static register(options: IaeModuleOptions = {}): DynamicModule { + return { + module: IaeModule, + controllers: [InboxController, EvidenceGrantController], + providers: [ + { + provide: ARTIFACT_INTAKE_REPOSITORY_PORT, + useValue: + options.artifactIntakeRepository ?? + (options.artifactIntakeDatabase === undefined + ? new InMemoryArtifactIntakeRepositoryAdapter() + : new PrismaArtifactIntakeRepositoryAdapter(options.artifactIntakeDatabase)), + }, + { + provide: ARTIFACT_REPOSITORY_PORT, + useValue: + options.artifactRepository ?? + (options.artifactDatabase === undefined + ? new InMemoryArtifactRepositoryAdapter() + : new PrismaArtifactRepositoryAdapter(options.artifactDatabase)), + }, + { + provide: EVIDENCE_GRANT_REPOSITORY_PORT, + useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [ + ARTIFACT_INTAKE_REPOSITORY_PORT, + ARTIFACT_REPOSITORY_PORT, + EVIDENCE_GRANT_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..05ae6ade --- /dev/null +++ b/services/api/src/platform/http/request-tenant-context.port.ts @@ -0,0 +1,17 @@ +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 { + void request; + await Promise.resolve(); + throw new Error('AUTHENTICATED_CONTEXT_UNAVAILABLE'); + } +} 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..63be8aa7 --- /dev/null +++ b/services/api/test/features/dsm/governed-dataset.service.test.ts @@ -0,0 +1,86 @@ +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, + ); +}); 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..75aa27fd --- /dev/null +++ b/services/api/test/features/dsm/mapping.service.test.ts @@ -0,0 +1,79 @@ +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, + ); +}); 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); +}); 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], + ); +}); 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'); +}); 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); +}); 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..c691e746 --- /dev/null +++ b/services/api/test/features/dsm/reference-entity.service.test.ts @@ -0,0 +1,120 @@ +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' }); +}); 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..b0dff08c --- /dev/null +++ b/services/api/test/features/dsm/rule-set.service.test.ts @@ -0,0 +1,80 @@ +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' }); +}); 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..374933c4 --- /dev/null +++ b/services/api/test/features/iae/artifact-governance.service.test.ts @@ -0,0 +1,117 @@ +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', + }, + }); +}); 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..6a7b8f5e --- /dev/null +++ b/services/api/test/features/iae/artifact-intake.service.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +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; + +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' }); +}); + +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], + ); +}); 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..a8077303 --- /dev/null +++ b/services/api/test/features/iae/derived-artifact.service.test.ts @@ -0,0 +1,182 @@ +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, + ); +}); 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..79e4f1c1 --- /dev/null +++ b/services/api/test/features/iae/evidence-grant.service.test.ts @@ -0,0 +1,101 @@ +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' }, + ); +}); + +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' }, + ); +}); 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(); + } +}); 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, + ); +}); 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); +}); 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(); } diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 405008c5..420bace2 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -63,7 +63,17 @@ 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', + '/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', ]); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index c6741166..78b70a0c 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -53,9 +53,17 @@ 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 "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"/); + 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"/); @@ -76,6 +84,10 @@ 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', + '20260802110000_dsm_mappings_rules', + '20260802120000_iae_evidence_grants', + '20260802130000_iae_dsm_scope_hardening', 'migration_lock.toml', ]); const migration = await readFile( @@ -205,4 +217,57 @@ 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, '\\$&')), + ); + } + 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, '\\$&')), + ); + } + 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, '\\$&')), + ); + } }); 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..81a251ef --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/dataset_profile.py @@ -0,0 +1,131 @@ +"""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..24e50f39 --- /dev/null +++ b/services/engine/tests/test_dataset_profile.py @@ -0,0 +1,58 @@ +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([], [])