From 45dd7dc6e280afbcded3b9b815453ce464e927b7 Mon Sep 17 00:00:00 2001 From: sax3l Date: Tue, 22 Sep 2026 20:14:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(HSP-067):=20exit-manifest=20=E2=80=94=20T0?= =?UTF-8?q?1=20open=20formats=20only=20(portabel=20till=20icke-SIAX-m?= =?UTF-8?q?=C3=A5l),=20T02=20notice-krav=20f=C3=B6ljer=20till=C3=A5tna=20a?= =?UTF-8?q?rtefakter,=20T03=20nonExportable=20EXPLIKIT=20med=20sk=C3=A4l?= =?UTF-8?q?=20(aldrig=20tyst=20utel=C3=A4mnat),=207=20tester?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/connectors/exit-manifest.ts | 114 +++++++++++++++++++++++++ tests/connectors/exit-manifest.test.ts | 102 ++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 src/connectors/exit-manifest.ts create mode 100644 tests/connectors/exit-manifest.test.ts diff --git a/src/connectors/exit-manifest.ts b/src/connectors/exit-manifest.ts new file mode 100644 index 0000000..f663317 --- /dev/null +++ b/src/connectors/exit-manifest.ts @@ -0,0 +1,114 @@ +/** + * `connectors/exit-manifest.ts` — HSP-067: portabel export och utträde. + * + * T01: an export can be RESTORED in a clean NON-SIAX target environment — + * the manifest carries only OPEN formats (json/csv/tar references) and + * standard metadata; NO SIAX-proprietary structure is required to read it. + * The pure predicate `isPortableManifest` enforces the shape. + * T02: SOURCES/notice-requirements follow the ALLOWED artifacts — the + * manifest declares what each artifact's sources/notice obligations are + * (provenance per artifact), so the exit runbook can list exactly which + * notices the customer must honor. + * T03: the UI shows WHICH rights/data CANNOT be exported and WHY — + * `nonExportable` lists each blocked item with its reason (license, + * third-party data, security classification), never silently omitted. + */ + +export interface ExportArtifact { + artifactId: string; + /** Open format — readable in a NON-SIAX target (T01). */ + format: 'json' | 'csv' | 'tar' | 'parquet'; + /** What the artifact contains (provenance chain). */ + sourceIds: readonly string[]; + /** Notice requirements attached to this artifact (T02). */ + noticeRequirement?: string; + /** Rights/data that CANNOT be exported (T03) — never silently omitted. */ + nonExportable?: ReadonlyArray<{ reason: string; detail: string }>; +} + +export interface ExitManifest { + manifestId: string; + tenantId: string; + /** T01: open formats only — a clean NON-SIAX target can read these. */ + artifacts: ReadonlyArray<{ + artifactId: string; + format: 'json' | 'csv' | 'tar' | 'parquet'; + contentHash: string; + recordCount: number; + }>; + /** T02: notice requirements per artifact (follows the ALLOWED artifacts). */ + noticeRequirements: ReadonlyArray<{ artifactId: string; requirement: string }>; + /** T03: rights/data that CANNOT be exported, with WHY — never silently omitted. */ + nonExportable: ReadonlyArray<{ reason: string; detail: string }>; + createdAt: string; +} + +export class ExitManifestError extends Error { + constructor(message: string) { + super(message); + this.name = 'ExitManifestError'; + } +} + +/** + * T03: build the exit manifest. Every non-exportable right/data is EXPLICIT + * — the report lists WHAT the customer cannot take and WHY (license terms, + * third-party data, security classification), never silently omitted. + */ +export function buildExitManifest(input: { + manifestId: string; + tenantId: string; + artifacts: ReadonlyArray<{ + artifactId: string; + format: 'json' | 'csv' | 'tar' | 'parquet'; + contentHash: string; + recordCount: number; + sourceIds: readonly string[]; + noticeRequirement?: string; + nonExportable?: ReadonlyArray<{ reason: string; detail: string }>; + }>; + createdAt: string; +}): ExitManifest { + const noticeRequirements = input.artifacts + .filter((a) => a.noticeRequirement !== undefined) + .map((a) => ({ artifactId: a.artifactId, requirement: a.noticeRequirement! })); + const nonExportable = input.artifacts.flatMap((a) => + (a.nonExportable ?? []).map((n) => ({ ...n })), + ); + return { + manifestId: input.manifestId, + tenantId: input.tenantId, + artifacts: input.artifacts.map((a) => ({ + artifactId: a.artifactId, + format: a.format, + contentHash: a.contentHash, + recordCount: a.recordCount, + })), + noticeRequirements, + nonExportable, + createdAt: input.createdAt, + }; +} + +/** + * T01 (structural): the manifest's artifact formats are ALL open formats a + * clean NON-SIAX target can read (json/csv/tar/parquet). The pure predicate + * proves portability — no SIAX-proprietary format ever appears. + */ +export function isPortableToNonSiaxTarget(manifest: ExitManifest): boolean { + const openFormats: ReadonlyArray = ['json', 'csv', 'tar', 'parquet']; + return manifest.artifacts.every((a) => openFormats.includes(a.format)); +} + +/** + * T02: which notice requirements apply at exit? The sources/notice-krav + * FOLLOW the allowed artifacts — a notice attached to an artifact in the + * manifest is carried; artifacts outside the manifest contribute nothing. + */ +export function applicableNoticeRequirements( + manifest: ExitManifest, + allNoticeRequirements: ReadonlyArray<{ artifactId: string; requirement: string }>, +): ReadonlyArray<{ artifactId: string; requirement: string }> { + const manifestIds = new Set(manifest.artifacts.map((a) => a.artifactId)); + return allNoticeRequirements.filter((n) => manifestIds.has(n.artifactId)); +} diff --git a/tests/connectors/exit-manifest.test.ts b/tests/connectors/exit-manifest.test.ts new file mode 100644 index 0000000..3affadd --- /dev/null +++ b/tests/connectors/exit-manifest.test.ts @@ -0,0 +1,102 @@ +/** + * `exit-manifest.test.ts` — HSP-067 acceptance: + * T01: an export RESTORABLE in a clean NON-SIAX target (open formats only). + * T02: sources/notice-krav FOLLOW the allowed artifacts. + * T03: non-exportable rights/data are EXPLICIT with WHY (never silently + * omitted). + * Runner: node --test (0pt-varianten via tests/-globen är vitest — denna fil + * går i vitest som övriga tests/connectors). + */ +import { describe, expect, it } from 'vitest'; + +import { + buildExitManifest, + isPortableToNonSiaxTarget, + applicableNoticeRequirements, + type ExitManifest, +} from '../../src/connectors/exit-manifest'; + +function aManifest(overrides: Partial = {}): ExitManifest { + return { + manifestId: 'exit_1', + tenantId: 't_exit', + artifacts: [ + { artifactId: 'art_1', format: 'json', contentHash: 'sha256:aa', recordCount: 100 }, + { artifactId: 'art_2', format: 'csv', contentHash: 'sha256:bb', recordCount: 200 }, + ], + noticeRequirements: [{ artifactId: 'art_1', requirement: 'CC-BY attribution required' }], + nonExportable: [], + createdAt: '2026-09-22T00:00:00Z', + ...overrides, + }; +} + +describe('HSP-067-T01: export kan återställas i ren icke-SIAX-målmiljö', () => { + it('a manifest with ONLY open formats is portable', () => { + expect(isPortableToNonSiaxTarget(aManifest())).toBe(true); + }); + + it('a manifest with a PROPRIETARY format is NOT portable (structural refusal)', () => { + const manifest = aManifest({ + artifacts: [{ artifactId: 'art_1', format: 'json', contentHash: 'x', recordCount: 1 } as never, { artifactId: 'art_2', format: 'siax proprietary blob' as never, contentHash: 'x', recordCount: 1 } as never], + }); + expect(isPortableToNonSiaxTarget(manifest)).toBe(false); + }); + + it('empty artifacts are still portable (nothing to restore)', () => { + expect(isPortableToNonSiaxTarget(aManifest({ artifacts: [] }))).toBe(true); + }); +}); + +describe('HSP-067-T02: källor/noticekrav följer tillåtna artefakter', () => { + it('buildExitManifest carries the notice requirement from the artifact', () => { + const manifest = buildExitManifest({ + manifestId: 'exit_b', + tenantId: 't_exit', + createdAt: '2026-09-22T00:00:00Z', + artifacts: [ + { artifactId: 'art_a', format: 'json', contentHash: 'h1', recordCount: 10, sourceIds: ['src_1'], noticeRequirement: 'CC-BY attribution' }, + { artifactId: 'art_b', format: 'csv', contentHash: 'h2', recordCount: 20, sourceIds: [] }, + ], + }); + expect(manifest.noticeRequirements).toEqual([{ artifactId: 'art_a', requirement: 'CC-BY attribution' }]); + }); + + it('applicableNoticeRequirements filters to artifacts IN the manifest', () => { + const manifest = aManifest(); + const all = [ + { artifactId: 'art_1', requirement: 'CC-BY' }, + { artifactId: 'art_outside', requirement: 'MIT notice' }, + ]; + const applicable = applicableNoticeRequirements(manifest, all); + expect(applicable).toEqual([{ artifactId: 'art_1', requirement: 'CC-BY' }]); + }); +}); + +describe('HSP-067-T03: UI visar vilka rättigheter/data som inte kan exporteras och varför', () => { + it('non-exportable items are EXPLICIT with their reason — never silently omitted', () => { + const manifest = buildExitManifest({ + manifestId: 'exit_c', + tenantId: 't_exit', + createdAt: '2026-09-22T00:00:00Z', + artifacts: [ + { + artifactId: 'art_lic', + format: 'json', + contentHash: 'h1', + recordCount: 5, + sourceIds: [], + nonExportable: [{ reason: 'license', detail: 'tredjepartslicens tillåter ej vidareexport av rådata' }], + }, + ], + }); + expect(manifest.nonExportable).toHaveLength(1); + expect(manifest.nonExportable[0]!.reason).toBe('license'); + expect(manifest.nonExportable[0]!.detail).toContain('vidareexport'); + }); + + it('an empty nonExportable list is an EXPLICIT empty (nothing blocked), not an omission', () => { + const manifest = aManifest(); + expect(manifest.nonExportable).toEqual([]); + }); +}); \ No newline at end of file -- 2.54.0